peachsim 0.1.665 → 0.1.667
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/detox/index.ts +1 -1
- package/detox/navigation.ts +1 -0
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +2 -2
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +1 -1
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +1 -1
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +4 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/native-app-identity.cjs +1 -1
- package/dist-lib/native-app-identity.mjs +1 -1
- package/dist-lib/plist.cjs +1 -1
- package/dist-lib/plist.mjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +9 -5
- package/dist-lib/swift-compile.cjs +1 -1
- package/dist-lib/swift-compile.mjs +1 -1
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +1 -1
- package/kotlin/AppSources/dev/peach/app/PeachApp.kt +15 -3
- package/kotlin/ComposeSources/PeachComposeHost.kt +200 -0
- package/kotlin/Sources/org/json/JSON.kt +13 -0
- package/kotlin/fixtures/kotlin-app-variants/Anim.kt +62 -0
- package/kotlin/fixtures/kotlin-app-variants/Form.kt +47 -0
- package/kotlin/fixtures/kotlin-app-variants/Ime.kt +51 -0
- package/kotlin/fixtures/kotlin-app-variants/Note.kt +40 -0
- package/package.json +1 -1
- package/src/assetCatalogContract.ts +23 -0
- package/src/kotlin-host.ts +3 -0
- package/src/skills/builtin/a11y-review.ts +3 -1
- package/src/skills/builtin/perf-profile.ts +5 -3
|
@@ -9,9 +9,13 @@ import androidx.compose.runtime.setValue
|
|
|
9
9
|
import androidx.compose.runtime.snapshots.Snapshot
|
|
10
10
|
import androidx.compose.ui.LocalSystemTheme
|
|
11
11
|
import androidx.compose.ui.geometry.Offset
|
|
12
|
+
import androidx.compose.ui.geometry.Rect
|
|
12
13
|
import androidx.compose.ui.graphics.asComposeCanvas
|
|
13
14
|
import androidx.compose.ui.input.InputMode
|
|
14
15
|
import androidx.compose.ui.input.InputModeManager
|
|
16
|
+
import androidx.compose.ui.input.key.Key
|
|
17
|
+
import androidx.compose.ui.input.key.KeyEvent
|
|
18
|
+
import androidx.compose.ui.input.key.KeyEventType
|
|
15
19
|
import androidx.compose.ui.input.pointer.PointerEventType
|
|
16
20
|
import androidx.compose.ui.input.pointer.PointerType
|
|
17
21
|
import androidx.compose.ui.platform.FrameRecomposer
|
|
@@ -19,6 +23,7 @@ import androidx.compose.ui.platform.LocalFontFamilyResolver
|
|
|
19
23
|
import androidx.compose.ui.platform.PlatformArchitectureComponentsOwner
|
|
20
24
|
import androidx.compose.ui.platform.PlatformWindowInsets
|
|
21
25
|
import androidx.compose.ui.platform.PlatformContext
|
|
26
|
+
import androidx.compose.ui.platform.PlatformTextInputMethodRequest
|
|
22
27
|
import androidx.compose.ui.platform.WindowInfo
|
|
23
28
|
import androidx.compose.ui.platform.registerSkikoComposeImplementation
|
|
24
29
|
import androidx.compose.ui.scene.CanvasLayersComposeScene
|
|
@@ -34,6 +39,19 @@ import androidx.compose.ui.text.font.FontWeight
|
|
|
34
39
|
import androidx.compose.ui.text.font.PlatformTypefacesLoader
|
|
35
40
|
import androidx.compose.ui.text.font.createPlatformFontFamilyResolver
|
|
36
41
|
import androidx.compose.ui.text.font.platformTypefacesLoader
|
|
42
|
+
import androidx.compose.ui.text.input.BackspaceCommand
|
|
43
|
+
import androidx.compose.ui.text.input.CommitTextCommand
|
|
44
|
+
import androidx.compose.ui.text.input.EditCommand
|
|
45
|
+
import androidx.compose.ui.text.input.FinishComposingTextCommand
|
|
46
|
+
import androidx.compose.ui.text.input.ImeAction
|
|
47
|
+
import androidx.compose.ui.text.input.ImeOptions
|
|
48
|
+
import androidx.compose.ui.text.input.KeyboardCapitalization
|
|
49
|
+
import androidx.compose.ui.text.input.KeyboardType
|
|
50
|
+
import androidx.compose.ui.text.input.PlatformTextInputService
|
|
51
|
+
import androidx.compose.ui.text.input.SetComposingRegionCommand
|
|
52
|
+
import androidx.compose.ui.text.input.SetComposingTextCommand
|
|
53
|
+
import androidx.compose.ui.text.input.SetSelectionCommand
|
|
54
|
+
import androidx.compose.ui.text.input.TextFieldValue
|
|
37
55
|
import androidx.compose.ui.text.intl.LocaleList
|
|
38
56
|
import androidx.compose.ui.text.intl.setPlatformLocaleProvider
|
|
39
57
|
import androidx.compose.ui.text.platform.Font
|
|
@@ -45,6 +63,7 @@ import androidx.compose.ui.unit.LayoutDirection
|
|
|
45
63
|
import dev.onejs.one.OneNativeSourceViewDispatch
|
|
46
64
|
import kotlin.io.encoding.Base64
|
|
47
65
|
import kotlinx.coroutines.Dispatchers
|
|
66
|
+
import kotlinx.coroutines.awaitCancellation
|
|
48
67
|
import org.jetbrains.skiko.SystemTheme
|
|
49
68
|
import org.jetbrains.skia.BackendRenderTarget
|
|
50
69
|
import org.jetbrains.skia.Bitmap
|
|
@@ -94,6 +113,129 @@ internal class PeachComposePlatform(
|
|
|
94
113
|
override fun onSemanticsOwnerRemoved(semanticsOwner: SemanticsOwner) { owners.remove(semanticsOwner) }
|
|
95
114
|
override fun onSemanticsChange(semanticsOwner: SemanticsOwner) = Unit
|
|
96
115
|
override fun onLayoutChange(semanticsOwner: SemanticsOwner, semanticsNodeId: Int) = Unit
|
|
116
|
+
|
|
117
|
+
// the input method session the focused text field holds, android's
|
|
118
|
+
// InputConnection. each session gets a new token, so the host can tell a
|
|
119
|
+
// late close from the session that replaced it
|
|
120
|
+
var input: PeachInputSession? = null
|
|
121
|
+
private set
|
|
122
|
+
var onInputChange: () -> Unit = {}
|
|
123
|
+
private var nextInput = 1
|
|
124
|
+
|
|
125
|
+
override suspend fun startInputMethod(request: PlatformTextInputMethodRequest): Nothing {
|
|
126
|
+
val session = PeachInputSession(nextInput++, request)
|
|
127
|
+
input = session
|
|
128
|
+
onInputChange()
|
|
129
|
+
try { awaitCancellation() }
|
|
130
|
+
finally {
|
|
131
|
+
if (input === session) input = null
|
|
132
|
+
onInputChange()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// compose's keyboard controller: show and hide move the keyboard and keep
|
|
137
|
+
// the session, as Done does on android. a session opens with the keyboard
|
|
138
|
+
// up, as android's legacy text input service shows it on startInput
|
|
139
|
+
@Suppress("DEPRECATION")
|
|
140
|
+
override val textInputService: PlatformTextInputService = object : PlatformTextInputService {
|
|
141
|
+
override fun startInput(value: TextFieldValue, imeOptions: ImeOptions,
|
|
142
|
+
onEditCommand: (List<EditCommand>) -> Unit, onImeActionPerformed: (ImeAction) -> Unit) = Unit
|
|
143
|
+
override fun stopInput() = Unit
|
|
144
|
+
override fun showSoftwareKeyboard() { input?.request(true); onInputChange() }
|
|
145
|
+
override fun hideSoftwareKeyboard() { input?.request(false); onInputChange() }
|
|
146
|
+
override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) = Unit
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
internal class PeachInputSession(val token: Int, val request: PlatformTextInputMethodRequest) {
|
|
151
|
+
var keyboard = true
|
|
152
|
+
private set
|
|
153
|
+
// each show or hide compose asks for, so a repeat still reaches the keyboard
|
|
154
|
+
private var keyboardRequest = 0
|
|
155
|
+
private val options get() = request.imeOptions
|
|
156
|
+
|
|
157
|
+
// EditorInfo.android.kt: a single line field's default action is Done; a
|
|
158
|
+
// multi line field's default has no enter action, so enter types a newline
|
|
159
|
+
private val action: ImeAction? get() = when (options.imeAction) {
|
|
160
|
+
ImeAction.Default -> if (options.singleLine) ImeAction.Done else null
|
|
161
|
+
ImeAction.None -> null
|
|
162
|
+
else -> options.imeAction
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fun request(visible: Boolean) {
|
|
166
|
+
keyboard = visible
|
|
167
|
+
keyboardRequest++
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private fun rect(rect: Rect?) = rect?.let { JSONArray().put(it.left).put(it.top).put(it.right).put(it.bottom) }
|
|
171
|
+
|
|
172
|
+
fun state(): JSONObject {
|
|
173
|
+
val value = request.value()
|
|
174
|
+
return JSONObject().put("token", token).put("keyboard", keyboard).put("keyboardRequest", keyboardRequest)
|
|
175
|
+
.put("value", value.text).put("selectionStart", value.selection.start).put("selectionEnd", value.selection.end)
|
|
176
|
+
.put("singleLine", options.singleLine).put("enterInsertsNewline", action == null && !options.singleLine)
|
|
177
|
+
.put("keyboardType", when (options.keyboardType) {
|
|
178
|
+
KeyboardType.Number -> "number"
|
|
179
|
+
KeyboardType.Phone -> "phone"
|
|
180
|
+
KeyboardType.Uri -> "uri"
|
|
181
|
+
KeyboardType.Email -> "email"
|
|
182
|
+
KeyboardType.Password -> "password"
|
|
183
|
+
KeyboardType.NumberPassword -> "number-password"
|
|
184
|
+
KeyboardType.Decimal -> "decimal"
|
|
185
|
+
KeyboardType.Ascii -> "ascii"
|
|
186
|
+
else -> "text"
|
|
187
|
+
})
|
|
188
|
+
.put("imeAction", when (action) {
|
|
189
|
+
ImeAction.Done -> "done"
|
|
190
|
+
ImeAction.Go -> "go"
|
|
191
|
+
ImeAction.Search -> "search"
|
|
192
|
+
ImeAction.Send -> "send"
|
|
193
|
+
ImeAction.Next -> "next"
|
|
194
|
+
ImeAction.Previous -> "previous"
|
|
195
|
+
else -> "default"
|
|
196
|
+
})
|
|
197
|
+
.put("capitalization", when (options.capitalization) {
|
|
198
|
+
KeyboardCapitalization.Characters -> "characters"
|
|
199
|
+
KeyboardCapitalization.Words -> "words"
|
|
200
|
+
KeyboardCapitalization.Sentences -> "sentences"
|
|
201
|
+
else -> "none"
|
|
202
|
+
})
|
|
203
|
+
.put("autoCorrect", options.autoCorrect)
|
|
204
|
+
.put("field", rect(request.textFieldRectInRoot()))
|
|
205
|
+
.put("caret", rect(request.focusedRectInRoot()))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// one keyboard edit, applied as one batch the way RecordingInputConnection
|
|
209
|
+
// ends a batch edit. typed text commits as an IME's commitText does; a
|
|
210
|
+
// composition replaces its own region, else the edit's range
|
|
211
|
+
fun edit(command: JSONObject): Boolean {
|
|
212
|
+
val value = request.value()
|
|
213
|
+
val start = command.optInt("start")
|
|
214
|
+
val end = command.optInt("end")
|
|
215
|
+
val composing = value.composition?.let { it.start == start && it.end == end } == true
|
|
216
|
+
val commands: List<EditCommand> = when (command.getString("kind")) {
|
|
217
|
+
"text" -> listOf(CommitTextCommand(command.getString("text"), 1))
|
|
218
|
+
"backspace" -> listOf(BackspaceCommand())
|
|
219
|
+
"enter" -> {
|
|
220
|
+
val performed = action
|
|
221
|
+
if (performed != null) {
|
|
222
|
+
request.onImeAction?.invoke(performed)
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
if (options.singleLine) return false
|
|
226
|
+
listOf(CommitTextCommand("\n", 1))
|
|
227
|
+
}
|
|
228
|
+
"compositionStart" -> if (start == end) return true else listOf(SetComposingRegionCommand(start, end))
|
|
229
|
+
"replace" -> {
|
|
230
|
+
val text = command.getString("text")
|
|
231
|
+
val place = if (composing) emptyList() else listOf(FinishComposingTextCommand(), SetSelectionCommand(start, end))
|
|
232
|
+
place + if (command.getString("composition") == "update") SetComposingTextCommand(text, 1) else CommitTextCommand(text, 1)
|
|
233
|
+
}
|
|
234
|
+
else -> error("unknown input edit ${command.getString("kind")}")
|
|
235
|
+
}
|
|
236
|
+
request.onEditCommand(commands)
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
97
239
|
}
|
|
98
240
|
|
|
99
241
|
// android's default and sans-serif families use the same device font bytes as
|
|
@@ -150,12 +292,16 @@ internal class PeachComposeMount(
|
|
|
150
292
|
recomposer, platform.density, LayoutDirection.Ltr, null, platform,
|
|
151
293
|
invalidateLayout = { needsMeasure = true; invalidate(true) }, invalidateDraw = { invalidate(false) },
|
|
152
294
|
)
|
|
295
|
+
// the session's state as last reported; a change reports it again
|
|
296
|
+
private var reportedInput: String? = null
|
|
153
297
|
private var surface: Surface? = null
|
|
154
298
|
private var context: DirectContext? = null
|
|
155
299
|
private var target: BackendRenderTarget? = null
|
|
156
300
|
private var pixels: Bitmap? = null
|
|
157
301
|
private var size = IntSize.Zero
|
|
158
302
|
|
|
303
|
+
init { platform.onInputChange = { invalidate(false) } }
|
|
304
|
+
|
|
159
305
|
fun setContent(content: @Composable () -> Unit) {
|
|
160
306
|
try {
|
|
161
307
|
scene.setContent {
|
|
@@ -221,6 +367,11 @@ internal class PeachComposeMount(
|
|
|
221
367
|
for ((name, args) in frameState) events.add(JSONObject().put("name", name).put("args", args()))
|
|
222
368
|
frameState.clear()
|
|
223
369
|
reply.put("events", JSONArray(events))
|
|
370
|
+
val input = platform.input?.state()?.toString()
|
|
371
|
+
if (input != reportedInput) {
|
|
372
|
+
reportedInput = input
|
|
373
|
+
reply.put("input", input?.let { JSONObject(it) } ?: JSONObject.NULL)
|
|
374
|
+
}
|
|
224
375
|
events.clear()
|
|
225
376
|
if (gpu) checkNotNull(surface).flushAndSubmit()
|
|
226
377
|
else {
|
|
@@ -245,6 +396,37 @@ internal class PeachComposeMount(
|
|
|
245
396
|
invalidate(false)
|
|
246
397
|
}
|
|
247
398
|
|
|
399
|
+
private fun session(token: Int) = platform.input?.takeIf { it.token == token }
|
|
400
|
+
|
|
401
|
+
fun inputEdit(token: Int, command: String): Boolean {
|
|
402
|
+
val session = session(token) ?: return false
|
|
403
|
+
return session.edit(JSONObject(command)).also { invalidate(false) }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// a hardware key, pressed and released, through compose's key input as an
|
|
407
|
+
// android view dispatches it; true when compose consumed the press
|
|
408
|
+
fun inputKey(token: Int, name: String, ctrl: Boolean, shift: Boolean, alt: Boolean): Boolean {
|
|
409
|
+
if (session(token) == null) return false
|
|
410
|
+
val key = hardwareKeys[name] ?: name.singleOrNull()?.lowercaseChar()?.let { letterKeys["$it"] } ?: return false
|
|
411
|
+
val codePoint = name.singleOrNull()?.code ?: 0
|
|
412
|
+
fun send(type: KeyEventType) = scene.sendKeyEvent(KeyEvent(key, type, codePoint,
|
|
413
|
+
isCtrlPressed = ctrl, isAltPressed = alt, isShiftPressed = shift))
|
|
414
|
+
val consumed = send(KeyEventType.KeyDown)
|
|
415
|
+
send(KeyEventType.KeyUp)
|
|
416
|
+
invalidate(false)
|
|
417
|
+
return consumed
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
fun inputState(token: Int): String = session(token)?.state()?.toString() ?: ""
|
|
421
|
+
|
|
422
|
+
// the host gave focus to another input: the view loses focus, as
|
|
423
|
+
// View.clearFocus does, and compose closes the session itself
|
|
424
|
+
fun inputBlur(token: Int) {
|
|
425
|
+
if (session(token) == null) return
|
|
426
|
+
scene.focusManager.releaseFocus()
|
|
427
|
+
invalidate(false)
|
|
428
|
+
}
|
|
429
|
+
|
|
248
430
|
fun semantics(): String {
|
|
249
431
|
val nodes = JSONArray()
|
|
250
432
|
fun collect(node: SemanticsNode) {
|
|
@@ -274,6 +456,19 @@ internal class PeachComposeMount(
|
|
|
274
456
|
}
|
|
275
457
|
}
|
|
276
458
|
|
|
459
|
+
// the keys a hardware keyboard sends, by their DOM names
|
|
460
|
+
private val hardwareKeys = mapOf(
|
|
461
|
+
"ArrowLeft" to Key.DirectionLeft, "ArrowRight" to Key.DirectionRight,
|
|
462
|
+
"ArrowUp" to Key.DirectionUp, "ArrowDown" to Key.DirectionDown,
|
|
463
|
+
"Home" to Key.MoveHome, "End" to Key.MoveEnd, "PageUp" to Key.PageUp, "PageDown" to Key.PageDown,
|
|
464
|
+
"Tab" to Key.Tab, "Delete" to Key.Delete, "Backspace" to Key.Backspace,
|
|
465
|
+
"Enter" to Key.Enter, "Escape" to Key.Escape,
|
|
466
|
+
)
|
|
467
|
+
private val letterKeys = listOf(
|
|
468
|
+
Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J, Key.K, Key.L, Key.M,
|
|
469
|
+
Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z,
|
|
470
|
+
).mapIndexed { index, key -> "${'a' + index}" to key }.toMap()
|
|
471
|
+
|
|
277
472
|
internal val mounts = mutableMapOf<Int, PeachComposeMount>()
|
|
278
473
|
private var nextMount = 1
|
|
279
474
|
private var deviceLocale: String? = null
|
|
@@ -330,6 +525,11 @@ fun peachViewMount(source: String, view: String, hash: String, props: String,
|
|
|
330
525
|
}
|
|
331
526
|
@JsExport fun peachViewFrame(id: Int, millis: Double): String = checkNotNull(mounts[id]).frame((millis * 1_000_000).toLong())
|
|
332
527
|
@JsExport fun peachViewPointer(id: Int, phase: String, x: Float, y: Float, millis: Double) = checkNotNull(mounts[id]).pointer(phase, x, y, millis.toLong())
|
|
528
|
+
@JsExport fun peachViewInputEdit(id: Int, token: Int, command: String): Boolean = checkNotNull(mounts[id]).inputEdit(token, command)
|
|
529
|
+
@JsExport fun peachViewInputKey(id: Int, token: Int, key: String, ctrl: Boolean, shift: Boolean, alt: Boolean): Boolean =
|
|
530
|
+
checkNotNull(mounts[id]).inputKey(token, key, ctrl, shift, alt)
|
|
531
|
+
@JsExport fun peachViewInputState(id: Int, token: Int): String = checkNotNull(mounts[id]).inputState(token)
|
|
532
|
+
@JsExport fun peachViewInputBlur(id: Int, token: Int) = checkNotNull(mounts[id]).inputBlur(token)
|
|
333
533
|
@JsExport fun peachViewSemantics(id: Int): String = checkNotNull(mounts[id]).semantics()
|
|
334
534
|
@JsExport fun peachViewDispose(id: Int) { mounts.remove(id)?.close() }
|
|
335
535
|
|
|
@@ -207,6 +207,19 @@ class JSONObject() {
|
|
|
207
207
|
return this
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
// android's coercions: any value reads as its string; a number or a
|
|
211
|
+
// numeric string reads as an int, anything else as the fallback
|
|
212
|
+
fun getString(name: String): String = when (val value = get(name)) {
|
|
213
|
+
is String -> value
|
|
214
|
+
else -> value.toString()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
fun optInt(name: String, fallback: Int = 0): Int = when (val value = opt(name)) {
|
|
218
|
+
is Number -> value.toInt()
|
|
219
|
+
is String -> value.toDoubleOrNull()?.toInt() ?: fallback
|
|
220
|
+
else -> fallback
|
|
221
|
+
}
|
|
222
|
+
|
|
210
223
|
fun optBoolean(name: String): Boolean = when (val value = opt(name)) {
|
|
211
224
|
is Boolean -> value
|
|
212
225
|
is String -> value.equals("true", ignoreCase = true)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
package dev.contrast.kotlinapp
|
|
2
|
+
|
|
3
|
+
import androidx.compose.animation.AnimatedVisibility
|
|
4
|
+
import androidx.compose.animation.animateColorAsState
|
|
5
|
+
import androidx.compose.animation.core.animateDpAsState
|
|
6
|
+
import androidx.compose.foundation.background
|
|
7
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
8
|
+
import androidx.compose.foundation.layout.Box
|
|
9
|
+
import androidx.compose.foundation.layout.Column
|
|
10
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
11
|
+
import androidx.compose.foundation.layout.fillMaxWidth
|
|
12
|
+
import androidx.compose.foundation.layout.height
|
|
13
|
+
import androidx.compose.foundation.layout.padding
|
|
14
|
+
import androidx.compose.foundation.layout.safeDrawingPadding
|
|
15
|
+
import androidx.compose.foundation.layout.width
|
|
16
|
+
import androidx.compose.material3.Button
|
|
17
|
+
import androidx.compose.material3.Text
|
|
18
|
+
import androidx.compose.runtime.Composable
|
|
19
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
20
|
+
import androidx.compose.runtime.withFrameNanos
|
|
21
|
+
import androidx.compose.runtime.getValue
|
|
22
|
+
import androidx.compose.runtime.mutableStateOf
|
|
23
|
+
import androidx.compose.runtime.remember
|
|
24
|
+
import androidx.compose.runtime.setValue
|
|
25
|
+
import androidx.compose.ui.Modifier
|
|
26
|
+
import androidx.compose.ui.graphics.Color
|
|
27
|
+
import androidx.compose.ui.unit.dp
|
|
28
|
+
import kotlin.math.roundToInt
|
|
29
|
+
|
|
30
|
+
// a tap flips one state that AnimatedVisibility, animateDpAsState and
|
|
31
|
+
// animateColorAsState each animate on the frame clock; the label reads the
|
|
32
|
+
// animated width, so it settles only when the animation has finished. took
|
|
33
|
+
// is the frame clock time and frame count from the flip to the width's arrival
|
|
34
|
+
@Composable
|
|
35
|
+
fun Anim() {
|
|
36
|
+
var on by remember { mutableStateOf(false) }
|
|
37
|
+
var flips by remember { mutableStateOf(0) }
|
|
38
|
+
var took by remember { mutableStateOf("none") }
|
|
39
|
+
val target = if (on) 240.dp else 80.dp
|
|
40
|
+
val width by animateDpAsState(target, label = "width")
|
|
41
|
+
LaunchedEffect(flips) {
|
|
42
|
+
if (flips == 0) return@LaunchedEffect
|
|
43
|
+
val start = withFrameNanos { it }
|
|
44
|
+
var count = 0
|
|
45
|
+
var end = start
|
|
46
|
+
while (width != target) end = withFrameNanos { count += 1; it }
|
|
47
|
+
took = "${(end - start) / 1_000_000}ms $count frames"
|
|
48
|
+
}
|
|
49
|
+
val color by animateColorAsState(if (on) Color(0xFF3F51B5) else Color(0xFFE91E63), label = "color")
|
|
50
|
+
Column(
|
|
51
|
+
Modifier.fillMaxSize().background(Color(0xFFE8EEF8)).safeDrawingPadding().padding(16.dp),
|
|
52
|
+
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
53
|
+
) {
|
|
54
|
+
Text("Anim: $on width ${if (width == target) "${target.value.roundToInt()}" else "moving"}")
|
|
55
|
+
Button(onClick = { on = !on; flips += 1 }) { Text("Animate") }
|
|
56
|
+
Text("Took: $took")
|
|
57
|
+
Box(Modifier.width(width).height(48.dp).background(color))
|
|
58
|
+
AnimatedVisibility(on) {
|
|
59
|
+
Text("Panel", Modifier.fillMaxWidth().background(Color(0xFF009688)).padding(24.dp), color = Color.White)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package dev.contrast.kotlinapp
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.background
|
|
4
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
5
|
+
import androidx.compose.foundation.layout.Column
|
|
6
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
7
|
+
import androidx.compose.foundation.layout.fillMaxWidth
|
|
8
|
+
import androidx.compose.foundation.layout.padding
|
|
9
|
+
import androidx.compose.foundation.layout.safeDrawingPadding
|
|
10
|
+
import androidx.compose.foundation.text.KeyboardActions
|
|
11
|
+
import androidx.compose.foundation.text.KeyboardOptions
|
|
12
|
+
import androidx.compose.material3.OutlinedTextField
|
|
13
|
+
import androidx.compose.material3.Text
|
|
14
|
+
import androidx.compose.runtime.Composable
|
|
15
|
+
import androidx.compose.runtime.getValue
|
|
16
|
+
import androidx.compose.runtime.mutableStateOf
|
|
17
|
+
import androidx.compose.runtime.remember
|
|
18
|
+
import androidx.compose.runtime.setValue
|
|
19
|
+
import androidx.compose.ui.Modifier
|
|
20
|
+
import androidx.compose.ui.focus.onFocusChanged
|
|
21
|
+
import androidx.compose.ui.graphics.Color
|
|
22
|
+
import androidx.compose.ui.text.input.ImeAction
|
|
23
|
+
import androidx.compose.ui.unit.dp
|
|
24
|
+
|
|
25
|
+
// a single line field a tap focuses, typing fills and Done finishes, keeping
|
|
26
|
+
// its focus as android does
|
|
27
|
+
@Composable
|
|
28
|
+
fun Form() {
|
|
29
|
+
var name by remember { mutableStateOf("") }
|
|
30
|
+
var focused by remember { mutableStateOf(false) }
|
|
31
|
+
var done by remember { mutableStateOf(0) }
|
|
32
|
+
Column(
|
|
33
|
+
Modifier.fillMaxSize().background(Color(0xFFE8EEF8)).safeDrawingPadding().padding(16.dp),
|
|
34
|
+
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
35
|
+
) {
|
|
36
|
+
Text("Name: ${name.ifEmpty { "empty" }} focus $focused done $done")
|
|
37
|
+
OutlinedTextField(
|
|
38
|
+
value = name,
|
|
39
|
+
onValueChange = { name = it },
|
|
40
|
+
label = { Text("Name") },
|
|
41
|
+
singleLine = true,
|
|
42
|
+
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
43
|
+
keyboardActions = KeyboardActions(onDone = { done += 1; defaultKeyboardAction(ImeAction.Done) }),
|
|
44
|
+
modifier = Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused },
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
package dev.contrast.kotlinapp
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.background
|
|
4
|
+
import androidx.compose.foundation.layout.Column
|
|
5
|
+
import androidx.compose.foundation.layout.Spacer
|
|
6
|
+
import androidx.compose.foundation.layout.WindowInsets
|
|
7
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
8
|
+
import androidx.compose.foundation.layout.fillMaxWidth
|
|
9
|
+
import androidx.compose.foundation.layout.ime
|
|
10
|
+
import androidx.compose.foundation.layout.padding
|
|
11
|
+
import androidx.compose.foundation.layout.safeDrawingPadding
|
|
12
|
+
import androidx.compose.material3.OutlinedTextField
|
|
13
|
+
import androidx.compose.material3.Text
|
|
14
|
+
import androidx.compose.runtime.Composable
|
|
15
|
+
import androidx.compose.runtime.getValue
|
|
16
|
+
import androidx.compose.runtime.mutableStateOf
|
|
17
|
+
import androidx.compose.runtime.remember
|
|
18
|
+
import androidx.compose.runtime.setValue
|
|
19
|
+
import androidx.compose.ui.Modifier
|
|
20
|
+
import androidx.compose.ui.graphics.Color
|
|
21
|
+
import androidx.compose.ui.layout.onSizeChanged
|
|
22
|
+
import androidx.compose.ui.platform.LocalDensity
|
|
23
|
+
import androidx.compose.ui.unit.dp
|
|
24
|
+
|
|
25
|
+
// safe drawing padding holds a bottom bar above the keyboard, which the IME
|
|
26
|
+
// inset moves as the keyboard opens and closes. a window that fits system
|
|
27
|
+
// windows takes no IME inset, and lifted reports whether its content shrank
|
|
28
|
+
@Composable
|
|
29
|
+
fun Ime() {
|
|
30
|
+
var note by remember { mutableStateOf("") }
|
|
31
|
+
var height by remember { mutableStateOf(0) }
|
|
32
|
+
var tallest by remember { mutableStateOf(0) }
|
|
33
|
+
val shown = WindowInsets.ime.getBottom(LocalDensity.current) > 0
|
|
34
|
+
Column(
|
|
35
|
+
Modifier.fillMaxSize()
|
|
36
|
+
.onSizeChanged { height = it.height; tallest = maxOf(tallest, it.height) }
|
|
37
|
+
.background(Color(0xFFE8EEF8))
|
|
38
|
+
.safeDrawingPadding()
|
|
39
|
+
.padding(16.dp),
|
|
40
|
+
) {
|
|
41
|
+
Text("Ime: ${if (shown) "shown" else "hidden"} lifted ${height < tallest}")
|
|
42
|
+
OutlinedTextField(
|
|
43
|
+
value = note,
|
|
44
|
+
onValueChange = { note = it },
|
|
45
|
+
label = { Text("Note") },
|
|
46
|
+
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
|
47
|
+
)
|
|
48
|
+
Spacer(Modifier.weight(1f))
|
|
49
|
+
Text("Bottom bar", Modifier.fillMaxWidth().background(Color(0xFF3F51B5)).padding(16.dp), color = Color.White)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
package dev.contrast.kotlinapp
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.background
|
|
4
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
5
|
+
import androidx.compose.foundation.layout.Column
|
|
6
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
7
|
+
import androidx.compose.foundation.layout.fillMaxWidth
|
|
8
|
+
import androidx.compose.foundation.layout.padding
|
|
9
|
+
import androidx.compose.foundation.layout.safeDrawingPadding
|
|
10
|
+
import androidx.compose.material3.OutlinedTextField
|
|
11
|
+
import androidx.compose.material3.Text
|
|
12
|
+
import androidx.compose.runtime.Composable
|
|
13
|
+
import androidx.compose.runtime.getValue
|
|
14
|
+
import androidx.compose.runtime.mutableStateOf
|
|
15
|
+
import androidx.compose.runtime.remember
|
|
16
|
+
import androidx.compose.runtime.setValue
|
|
17
|
+
import androidx.compose.ui.Modifier
|
|
18
|
+
import androidx.compose.ui.focus.onFocusChanged
|
|
19
|
+
import androidx.compose.ui.graphics.Color
|
|
20
|
+
import androidx.compose.ui.unit.dp
|
|
21
|
+
|
|
22
|
+
// a multi line field with default keyboard options: enter inserts a newline
|
|
23
|
+
// and delete removes what it typed
|
|
24
|
+
@Composable
|
|
25
|
+
fun Note() {
|
|
26
|
+
var note by remember { mutableStateOf("") }
|
|
27
|
+
var focused by remember { mutableStateOf(false) }
|
|
28
|
+
Column(
|
|
29
|
+
Modifier.fillMaxSize().background(Color(0xFFE8EEF8)).safeDrawingPadding().padding(16.dp),
|
|
30
|
+
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
31
|
+
) {
|
|
32
|
+
Text("Note: ${note.ifEmpty { "empty" }.replace('\n', '|')} focus $focused")
|
|
33
|
+
OutlinedTextField(
|
|
34
|
+
value = note,
|
|
35
|
+
onValueChange = { note = it },
|
|
36
|
+
label = { Text("Note") },
|
|
37
|
+
modifier = Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused },
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -7,6 +7,27 @@ export interface SwiftImageSource {
|
|
|
7
7
|
// an app catalog color resolves by appearance or names a system color.
|
|
8
8
|
export type AppCatalogColor = { light: string; dark: string } | { system: string }
|
|
9
9
|
|
|
10
|
+
// UIImage's ordered monochrome layers and unrounded content bearings.
|
|
11
|
+
export interface SfSymbolImageDrawing {
|
|
12
|
+
referencePointSize: number
|
|
13
|
+
basePointSize: number
|
|
14
|
+
referenceCanvasSize: readonly [number, number]
|
|
15
|
+
insets: readonly [number, number, number, number]
|
|
16
|
+
layers: readonly { d: string; opacity: number; eraser: boolean }[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SfSymbolImageMetadata extends Omit<SfSymbolImageDrawing, 'layers'> {
|
|
20
|
+
layers: readonly { opacity: number; eraser: boolean }[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// roundtrip Float64 decimal coordinates with shared svg verb programs.
|
|
24
|
+
export interface SfSymbolPathEncoding {
|
|
25
|
+
format: 'float64-table'
|
|
26
|
+
coordinates: string
|
|
27
|
+
programs: readonly string[]
|
|
28
|
+
paths: readonly (readonly [number, string])[]
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
export interface SfSymbolVector {
|
|
11
32
|
viewBox: '0 0 24 24'
|
|
12
33
|
advance: number
|
|
@@ -17,6 +38,8 @@ export interface SfSymbolVector {
|
|
|
17
38
|
uiImageBox?: readonly [number, number, number, number]
|
|
18
39
|
controlBoxes?: Record<string, readonly [number, number, number]>
|
|
19
40
|
layers: readonly { d: string; color?: string }[]
|
|
41
|
+
image?: SfSymbolImageDrawing
|
|
42
|
+
imageAliases?: Readonly<Record<string, SfSymbolImageDrawing>>
|
|
20
43
|
}
|
|
21
44
|
|
|
22
45
|
// the three symbol scales. the symbol font draws each as its own glyph
|
package/src/kotlin-host.ts
CHANGED
|
@@ -401,6 +401,9 @@ export async function kotlinAppArtifact(
|
|
|
401
401
|
'@JsExport',
|
|
402
402
|
'fun peachAppNight(night: Boolean) = dev.peach.app.peachAppNight(night)',
|
|
403
403
|
'',
|
|
404
|
+
'@JsExport',
|
|
405
|
+
'fun peachAppIme(bottom: Int) = dev.peach.app.peachAppIme(bottom)',
|
|
406
|
+
'',
|
|
404
407
|
].join('\n')
|
|
405
408
|
const generated: Record<string, string> = {
|
|
406
409
|
'PeachEntry.kt': entrySource([]),
|
|
@@ -31,7 +31,9 @@ export const skill: RNXSkill = {
|
|
|
31
31
|
|
|
32
32
|
try {
|
|
33
33
|
await page.goto(context.url, { waitUntil: 'networkidle' })
|
|
34
|
-
await page.waitForFunction('window.__sootsimTest', {
|
|
34
|
+
await page.waitForFunction('window.__sootsimTest', undefined, {
|
|
35
|
+
timeout: 10000,
|
|
36
|
+
})
|
|
35
37
|
await page.waitForTimeout(2000)
|
|
36
38
|
|
|
37
39
|
// get accessibility tree
|
|
@@ -44,9 +44,11 @@ export const skill: RNXSkill = {
|
|
|
44
44
|
|
|
45
45
|
try {
|
|
46
46
|
await page.goto(context.url, { waitUntil: 'networkidle' })
|
|
47
|
-
await page.waitForFunction(
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
await page.waitForFunction(
|
|
48
|
+
'window.__sootsimTest && window.__sootsimA11y',
|
|
49
|
+
undefined,
|
|
50
|
+
{ timeout: 10000 },
|
|
51
|
+
)
|
|
50
52
|
await page.waitForTimeout(1000)
|
|
51
53
|
|
|
52
54
|
// reset profiler
|