@g9tm/lynxcontrols-native 1.4.3 → 1.4.5

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/README.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # @g9tm/lynxcontrols-native
2
2
 
3
- Android and iOS platform layer for G9: two Lynx native modules and one element (`<g9-video>`), linked into a host by
3
+ Android and iOS platform layer for G9: three Lynx native modules and one element (`<g9-video>`), linked into a host by
4
4
  **Lynx Autolink** from `node_modules`, plus their JS adapters.
5
5
 
6
6
  | Module | JS adapter | Android (Kotlin) | iOS (Objective-C) |
7
7
  | --- | --- | --- | --- |
8
8
  | `G9SqliteModule` | `createNativeSqliteDriver()` | androidx.sqlite `BundledSQLiteDriver` 2.7.1 | system `sqlite3` (iOS 15+, serial queue per session, `sqlite3_interrupt`) |
9
9
  | `G9HostModule` | `createNativeHostServices()` | `SharedPreferences`, `moveTaskToBack`, `InputMethodManager`, haptics, insets, `appDataDirectory()` = `filesDir` | `NSUserDefaults` suite, `resignFirstResponder`, `UIImpactFeedbackGenerator`, safe-area insets, `appDataDirectory()` = `Library` |
10
+ | `G9SpeechModule` | `createNativeSpeechToText()` → `services.speech` (dictation into fields) | `SpeechRecognizer` (main thread, partial results), the microphone asked through the host activity's `ActivityResultRegistry` | `SFSpeechRecognizer` over `AVAudioEngine`; register it on the host's `LynxConfig`; Info.plist `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription` |
10
11
  | `<g9-video>` element | `createNativeMediaProvider()` (`@g9tm/lynxcontrols-native/media`) | `G9VideoUI`: Media3 ExoPlayer 1.8.0 into a `TextureView` (opacity fades work); register `G9VideoBehaviors.create()` | `G9VideoUI`: `AVQueuePlayer` + `AVPlayerLooper` in an `AVPlayerLayer`; self-registers (`LYNX_REGISTER_UI`) |
11
12
 
12
13
  ```ts
@@ -21,6 +22,17 @@ const db = createG9Sqlite({ driver: createNativeSqliteDriver(), locator: singleF
21
22
 
22
23
  ## How it works
23
24
 
25
+ - **Dictation** (`services.speech`, MAUI `IG9SpeechToText`). `createNativeSpeechToText()` returns `null` where
26
+ `G9SpeechModule` is not linked, so the fields hide their microphone. One session at a time natively; the adapter
27
+ numbers sessions, routes the `g9:speech` global event (`{"session","text"}`, interim hypotheses) to the session that
28
+ asked, turns an abort into `cancel(session)` and settles every `listen` once (`recognized` / `no-match` /
29
+ `cancelled` / `denied`; a recogniser fault rejects with a `G9Error`). Android: the manifest merges `RECORD_AUDIO` and
30
+ the `RecognitionService` query Android 11+ needs to see a recogniser; androidx.activity is compile-only (every host
31
+ has it; a host activity that is not an `ActivityResultRegistryOwner` gets the current permission state, never a
32
+ prompt).
33
+ `isAvailable(locale)` is "a recogniser exists" on Android (asking a locale needs a broadcast some recognisers never
34
+ answer; an unsupported language ends as `no-match`) and `SFSpeechRecognizer(locale).isAvailable` on iOS.
35
+
24
36
  - **Video** (the intro carousel's `services.media`). `<g9-video>` props: `src`, `paused`, `loop`, `muted`,
25
37
  `start-seconds`, `near-end-seconds`, `object-fit`; events `ready`, `nearend`, `looped`, `error`. The provider's
26
38
  player renders it with reactive props (no UI-method calls) and unmounts it on `dispose()`, which releases the native
@@ -54,13 +66,16 @@ android/src/main/java/com/g9lynx/g9native/
54
66
  sqlite/core/ pure Kotlin: G9SqliteCore, G9SqliteService (executors), G9SqliteTransport, values/errors
55
67
  sqlite/androidx/ AndroidxSqlEngine (BundledSQLiteDriver)
56
68
  host/core/ G9PreferencesCore
57
- G9SqliteModule.kt, G9HostModule.kt, G9Bridge.kt Lynx modules (kapt + lynx-processor → LynxLibraryProviderImpl)
69
+ G9SqliteModule.kt, G9HostModule.kt, G9SpeechModule.kt, G9Bridge.kt Lynx modules (kapt + lynx-processor →
70
+ LynxLibraryProviderImpl)
58
71
  android/src/test/ JVM tests: contract runner, core, service, transport golden, preferences
59
72
  ios/src/core/ portable Objective-C (Foundation + sqlite3): G9SqliteCore, G9SqliteTransport, G9PreferencesCore
60
- ios/src/ G9SqliteModule.m, G9HostModule.m (dispatch queues, UIKit); register them on the host's LynxConfig
73
+ ios/src/ G9SqliteModule.m, G9HostModule.m, G9SpeechModule.m (dispatch queues, UIKit, Speech); register them
74
+ on the host's LynxConfig
61
75
  ios/Tests/ G9NativeTestSuite (XCTest on a simulator; Linux harness in Tests/linux)
62
76
  jvm/ JVM-only Gradle build of the core + tests (no Android SDK needed)
63
- android-check/ cloud compile check of module + reference host against the published Lynx 4.1.0 classes, and
77
+ android-check/ cloud compile check of module + reference host against the published Lynx 4.1.0 classes (Media3,
78
+ androidx.sqlite and androidx.activity as compile-only stubs under stubs/), and
64
79
  LynxBridgeSignatureTest: every @LynxMethod through Lynx's own LynxMethodWrapper
65
80
  ```
66
81
 
@@ -34,6 +34,14 @@ dependencies {
34
34
  // The generated specs import androidx.annotation and lynx-processor's output uses @Keep, but Lynx's POMs declare it
35
35
  // at runtime scope only: without this line kapt depends on androidx.sqlite / media3 bringing it in transitively.
36
36
  compileOnly("androidx.annotation:annotation:1.9.1")
37
+ // G9InputBehaviors (input/G9InputUI.kt) extends xelement-input's input and textarea; a host that registers them
38
+ // already depends on it.
39
+ compileOnly("org.lynxsdk.lynx:xelement-input:$lynx")
40
+ // Its EditText derives from AppCompatEditText, which xelement-input's POM lists at runtime scope only.
41
+ compileOnly("androidx.appcompat:appcompat:1.7.0")
42
+ // G9SpeechModule asks for the microphone through the host activity's ActivityResultRegistry: compile-only, as every
43
+ // host of these libraries ships androidx.activity (a host without it gets the current state, never a prompt).
44
+ compileOnly("androidx.activity:activity:1.10.1")
37
45
  implementation("androidx.sqlite:sqlite-bundled:2.7.1")
38
46
  // <g9-video> (video/G9VideoUI.kt): ExoPlayer drawing into a TextureView. Google Maven only.
39
47
  implementation("androidx.media3:media3-exoplayer:1.8.0")
@@ -1,5 +1,12 @@
1
1
  <manifest
2
2
  xmlns:android="http://schemas.android.com/apk/res/android"
3
3
  package="com.g9lynx.g9native">
4
+ <!-- G9SpeechModule: dictation records the microphone, and Android 11+ only sees a recogniser it declares a query for. -->
5
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
6
+ <queries>
7
+ <intent>
8
+ <action android:name="android.speech.RecognitionService" />
9
+ </intent>
10
+ </queries>
4
11
  <application />
5
12
  </manifest>
@@ -13,6 +13,7 @@ import android.view.inputmethod.InputMethodManager
13
13
  import com.g9lynx.g9native.generated.G9HostModuleSpec
14
14
  import com.g9lynx.g9native.host.core.G9PreferenceBackend
15
15
  import com.g9lynx.g9native.host.core.G9PreferencesCore
16
+ import com.g9lynx.g9native.host.core.G9SafeAreaCore
16
17
  import com.lynx.jsbridge.LynxMethod
17
18
  import com.lynx.jsbridge.LynxNativeModule
18
19
  import com.lynx.react.bridge.Callback
@@ -86,7 +87,7 @@ class G9HostModule(private val lynxContext: LynxContext) : G9HostModuleSpec(lynx
86
87
  val density = activity.resources.displayMetrics.density
87
88
  val insets = activity.window.decorView.rootWindowInsets
88
89
  val rtl = activity.window.decorView.layoutDirection == View.LAYOUT_DIRECTION_RTL
89
- val px = safeInsetsPx(insets)
90
+ val px = safeInsetsPx(insets, activity.window.decorView)
90
91
  val left = px[0] / density
91
92
  val right = px[2] / density
92
93
  val value =
@@ -160,13 +161,38 @@ class G9HostModule(private val lynxContext: LynxContext) : G9HostModuleSpec(lynx
160
161
  * by type, so the soft keyboard never counts (the deprecated `systemWindowInset*` added the IME height when the
161
162
  * keyboard was up) and a host that hides the bars (immersive, as the AgriPad MAUI app) gets the cutout only.
162
163
  */
163
- private fun safeInsetsPx(insets: android.view.WindowInsets?): IntArray {
164
+ /** The cutout plus each system bar only while it shows (G9SafeAreaCore): an immersive host's hidden bars count zero. */
165
+ private fun safeInsetsPx(insets: android.view.WindowInsets?, decor: View): IntArray {
164
166
  if (insets == null) return intArrayOf(0, 0, 0, 0)
165
167
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
166
- val i = insets.getInsets(android.view.WindowInsets.Type.systemBars() or android.view.WindowInsets.Type.displayCutout())
167
- return intArrayOf(i.left, i.top, i.right, i.bottom)
168
+ fun px(i: android.graphics.Insets) = intArrayOf(i.left, i.top, i.right, i.bottom)
169
+ val status = android.view.WindowInsets.Type.statusBars()
170
+ val navigation = android.view.WindowInsets.Type.navigationBars()
171
+ return G9SafeAreaCore.compute(
172
+ px(insets.getInsets(android.view.WindowInsets.Type.displayCutout())),
173
+ px(insets.getInsets(status)),
174
+ insets.isVisible(status),
175
+ px(insets.getInsets(navigation)),
176
+ insets.isVisible(navigation),
177
+ )
168
178
  }
179
+ // Android 10 and older: systemWindowInsets keep a hidden bar's size (LAYOUT_STABLE); the decor view's flags say
180
+ // whether it is hidden. The status bar is the top inset, the navigation bar the others.
181
+ @Suppress("DEPRECATION")
182
+ val flags = decor.systemUiVisibility
183
+ val cutout =
184
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
185
+ insets.displayCutout?.let { intArrayOf(it.safeInsetLeft, it.safeInsetTop, it.safeInsetRight, it.safeInsetBottom) }
186
+ } else {
187
+ null
188
+ } ?: intArrayOf(0, 0, 0, 0)
169
189
  @Suppress("DEPRECATION")
170
- return intArrayOf(insets.systemWindowInsetLeft, insets.systemWindowInsetTop, insets.systemWindowInsetRight, insets.systemWindowInsetBottom)
190
+ return G9SafeAreaCore.compute(
191
+ cutout,
192
+ intArrayOf(0, insets.systemWindowInsetTop, 0, 0),
193
+ flags and View.SYSTEM_UI_FLAG_FULLSCREEN == 0,
194
+ intArrayOf(insets.systemWindowInsetLeft, 0, insets.systemWindowInsetRight, insets.systemWindowInsetBottom),
195
+ flags and View.SYSTEM_UI_FLAG_HIDE_NAVIGATION == 0,
196
+ )
171
197
  }
172
198
  }
@@ -0,0 +1,206 @@
1
+ package com.g9lynx.g9native
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.content.Context
6
+ import android.content.ContextWrapper
7
+ import android.content.Intent
8
+ import android.content.pm.PackageManager
9
+ import android.os.Bundle
10
+ import android.os.Handler
11
+ import android.os.Looper
12
+ import android.speech.RecognitionListener
13
+ import android.speech.RecognizerIntent
14
+ import android.speech.SpeechRecognizer
15
+ import androidx.activity.result.ActivityResultLauncher
16
+ import androidx.activity.result.ActivityResultRegistryOwner
17
+ import androidx.activity.result.contract.ActivityResultContracts
18
+ import com.g9lynx.g9native.generated.G9SpeechModuleSpec
19
+ import com.lynx.jsbridge.LynxMethod
20
+ import com.lynx.jsbridge.LynxNativeModule
21
+ import com.lynx.react.bridge.Callback
22
+ import com.lynx.react.bridge.JavaOnlyArray
23
+ import com.lynx.tasm.behavior.LynxContext
24
+ import org.json.JSONObject
25
+ import java.util.concurrent.atomic.AtomicInteger
26
+
27
+ /**
28
+ * `NativeModules.G9SpeechModule` (`G9SpeechToText`, MAUI `IG9SpeechToText`): Android's `SpeechRecognizer`, one session at
29
+ * a time, on the main thread (the recogniser demands it). Partial hypotheses go out as the global event `g9:speech`
30
+ * (`{"session","text"}`); the `listen` reply settles once. The microphone permission is asked through the activity's
31
+ * `ActivityResultRegistry` — the host's activity must be an `ActivityResultRegistryOwner` (every `ComponentActivity`;
32
+ * androidx.activity is compile-only here, every host of these libraries has it); another activity answers with the
33
+ * current state instead of a prompt.
34
+ * TWIN: ios/src/G9SpeechModule.m.
35
+ */
36
+ @LynxNativeModule(name = "G9SpeechModule")
37
+ class G9SpeechModule(private val lynxContext: LynxContext) : G9SpeechModuleSpec(lynxContext) {
38
+ private val main = Handler(Looper.getMainLooper())
39
+
40
+ private class Session(val id: String, val recognizer: SpeechRecognizer, val callback: Callback?) {
41
+ var settled = false
42
+ }
43
+
44
+ private var current: Session? = null
45
+
46
+ private fun activity(): Activity? {
47
+ var context: Context? = lynxContext
48
+ while (context is ContextWrapper) {
49
+ if (context is Activity) return context
50
+ context = context.baseContext
51
+ }
52
+ return null
53
+ }
54
+
55
+ private fun ok(callback: Callback?, value: Any?) = G9Bridge.reply(callback, mapOf("ok" to true, "value" to value))
56
+
57
+ private fun fail(callback: Callback?, code: String, message: String) =
58
+ G9Bridge.reply(callback, mapOf("ok" to false, "error" to mapOf("code" to code, "message" to message)))
59
+
60
+ private fun micGranted(): Boolean =
61
+ lynxContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
62
+
63
+ @LynxMethod
64
+ override fun isAvailable(locale: String?, callback: Callback?) {
65
+ // The locale cannot be asked without starting a session (RecognizerIntent.ACTION_GET_LANGUAGE_DETAILS needs a
66
+ // broadcast round-trip that some recognisers never answer): a recogniser that exists is "available"; an unsupported
67
+ // language then ends its session as no-match.
68
+ main.post { ok(callback, SpeechRecognizer.isRecognitionAvailable(lynxContext)) }
69
+ }
70
+
71
+ @LynxMethod
72
+ override fun requestPermission(callback: Callback?) {
73
+ if (micGranted()) return ok(callback, "granted")
74
+ main.post {
75
+ val host = activity()
76
+ try {
77
+ if (host !is ActivityResultRegistryOwner || host.isFinishing) return@post ok(callback, "denied")
78
+ var launcher: ActivityResultLauncher<String>? = null
79
+ launcher =
80
+ host.activityResultRegistry.register("g9.speech.mic." + nextKey.incrementAndGet(), ActivityResultContracts.RequestPermission()) { granted ->
81
+ launcher?.unregister()
82
+ ok(callback, if (granted) "granted" else "denied")
83
+ }
84
+ launcher.launch(Manifest.permission.RECORD_AUDIO)
85
+ } catch (t: Throwable) {
86
+ // No androidx.activity in this host, or the registry refused: say what is true now rather than fail.
87
+ ok(callback, if (micGranted()) "granted" else "denied")
88
+ }
89
+ }
90
+ }
91
+
92
+ @LynxMethod
93
+ override fun listen(session: String?, locale: String?, callback: Callback?) {
94
+ val id = session ?: ""
95
+ main.post {
96
+ if (!micGranted()) return@post ok(callback, mapOf("status" to "denied"))
97
+ if (!SpeechRecognizer.isRecognitionAvailable(lynxContext)) return@post fail(callback, "speech-unavailable", "No speech recogniser on this device.")
98
+ current?.let { finish(it, mapOf("status" to "cancelled")) }
99
+ val recognizer =
100
+ try {
101
+ SpeechRecognizer.createSpeechRecognizer(lynxContext)
102
+ } catch (t: Throwable) {
103
+ return@post fail(callback, "speech-recognition", t.message ?: "The recogniser could not start.")
104
+ }
105
+ val s = Session(id, recognizer, callback)
106
+ current = s
107
+ recognizer.setRecognitionListener(listener(s))
108
+ val intent =
109
+ Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
110
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
111
+ putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
112
+ putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
113
+ if (!locale.isNullOrBlank()) {
114
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, locale)
115
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, locale)
116
+ }
117
+ putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, lynxContext.packageName)
118
+ }
119
+ try {
120
+ recognizer.startListening(intent)
121
+ } catch (t: Throwable) {
122
+ finishWithError(s, "speech-recognition", t.message ?: "The recogniser could not start.")
123
+ }
124
+ }
125
+ }
126
+
127
+ @LynxMethod
128
+ override fun cancel(session: String?) {
129
+ main.post {
130
+ val s = current ?: return@post
131
+ if (s.id != session) return@post
132
+ try {
133
+ s.recognizer.cancel()
134
+ } catch (_: Throwable) {
135
+ }
136
+ finish(s, mapOf("status" to "cancelled"))
137
+ }
138
+ }
139
+
140
+ private fun listener(s: Session) =
141
+ object : RecognitionListener {
142
+ override fun onReadyForSpeech(params: Bundle?) {}
143
+
144
+ override fun onBeginningOfSpeech() {}
145
+
146
+ override fun onRmsChanged(rmsdB: Float) {}
147
+
148
+ override fun onBufferReceived(buffer: ByteArray?) {}
149
+
150
+ override fun onEndOfSpeech() {}
151
+
152
+ override fun onEvent(eventType: Int, params: Bundle?) {}
153
+
154
+ override fun onPartialResults(partialResults: Bundle?) {
155
+ val text = best(partialResults) ?: return
156
+ if (s.settled || text.isEmpty()) return
157
+ val args = JavaOnlyArray().apply { pushString(JSONObject(mapOf("session" to s.id, "text" to text)).toString()) }
158
+ try {
159
+ lynxContext.sendGlobalEvent("g9:speech", args)
160
+ } catch (_: Throwable) {
161
+ }
162
+ }
163
+
164
+ override fun onResults(results: Bundle?) {
165
+ val text = best(results)?.trim().orEmpty()
166
+ finish(s, if (text.isEmpty()) mapOf("status" to "no-match") else mapOf("status" to "recognized", "text" to text))
167
+ }
168
+
169
+ override fun onError(error: Int) {
170
+ when (error) {
171
+ SpeechRecognizer.ERROR_NO_MATCH, SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> finish(s, mapOf("status" to "no-match"))
172
+ SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> finish(s, mapOf("status" to "denied"))
173
+ SpeechRecognizer.ERROR_CLIENT -> finish(s, mapOf("status" to "cancelled"))
174
+ else -> finishWithError(s, "speech-recognition", "Speech recognition failed (SpeechRecognizer error $error).")
175
+ }
176
+ }
177
+ }
178
+
179
+ private fun best(bundle: Bundle?): String? = bundle?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()
180
+
181
+ private fun finish(s: Session, value: Map<String, Any?>) {
182
+ if (s.settled) return
183
+ s.settled = true
184
+ release(s)
185
+ ok(s.callback, value)
186
+ }
187
+
188
+ private fun finishWithError(s: Session, code: String, message: String) {
189
+ if (s.settled) return
190
+ s.settled = true
191
+ release(s)
192
+ fail(s.callback, code, message)
193
+ }
194
+
195
+ private fun release(s: Session) {
196
+ if (current === s) current = null
197
+ try {
198
+ s.recognizer.destroy()
199
+ } catch (_: Throwable) {
200
+ }
201
+ }
202
+
203
+ private companion object {
204
+ val nextKey = AtomicInteger()
205
+ }
206
+ }
@@ -0,0 +1,27 @@
1
+ // Generated by @lynx-js/autolink-codegen. Do not edit.
2
+ // Parameter types: scripts/android-bridge-types.mjs --fix (Lynx's bridge rejects the generator's Object).
3
+ package com.g9lynx.g9native.generated;
4
+
5
+ import androidx.annotation.Nullable;
6
+ import com.lynx.jsbridge.LynxContextModule;
7
+ import com.lynx.jsbridge.LynxMethod;
8
+ import com.lynx.react.bridge.Callback;
9
+ import com.lynx.tasm.behavior.LynxContext;
10
+
11
+ public abstract class G9SpeechModuleSpec extends LynxContextModule {
12
+ public G9SpeechModuleSpec(LynxContext context) {
13
+ super(context);
14
+ }
15
+
16
+ @LynxMethod
17
+ public abstract void isAvailable(String locale, Callback callback);
18
+
19
+ @LynxMethod
20
+ public abstract void requestPermission(Callback callback);
21
+
22
+ @LynxMethod
23
+ public abstract void listen(String session, String locale, Callback callback);
24
+
25
+ @LynxMethod
26
+ public abstract void cancel(String session);
27
+ }
@@ -0,0 +1,19 @@
1
+ package com.g9lynx.g9native.host.core
2
+
3
+ /**
4
+ * The safe area `G9HostModule.safeInsets` reports, as `[left, top, right, bottom]` px: the display cutout, plus a system bar
5
+ * only while it SHOWS. A host that hides its bars (immersive: they slide in transiently over the page) gets the cutout
6
+ * alone — the bars' own inset is reported whether or not they are hidden on Android 10 and older (`systemWindowInsets`
7
+ * under `SYSTEM_UI_FLAG_LAYOUT_STABLE`), and counting it put every bottom bar and sheet 48 dp above a navigation bar that
8
+ * was not there (AgriPad Lynx on a DOOGEE S96Pro, Android 10, 2026-11-02).
9
+ */
10
+ object G9SafeAreaCore {
11
+ fun compute(cutout: IntArray, status: IntArray, statusShown: Boolean, navigation: IntArray, navigationShown: Boolean): IntArray {
12
+ val out = cutout.copyOf(4)
13
+ for (side in 0 until 4) {
14
+ if (statusShown) out[side] = maxOf(out[side], status.getOrElse(side) { 0 })
15
+ if (navigationShown) out[side] = maxOf(out[side], navigation.getOrElse(side) { 0 })
16
+ }
17
+ return out
18
+ }
19
+ }
@@ -0,0 +1,118 @@
1
+ package com.g9lynx.g9native.input
2
+
3
+ import android.text.method.PasswordTransformationMethod
4
+ import android.text.method.ReplacementTransformationMethod
5
+ import android.text.method.SingleLineTransformationMethod
6
+ import android.text.method.TransformationMethod
7
+ import com.g9lynx.g9native.input.core.G9DigitsCore
8
+ import com.lynx.tasm.behavior.Behavior
9
+ import com.lynx.tasm.behavior.LynxContext
10
+ import com.lynx.tasm.behavior.LynxProp
11
+ import com.lynx.tasm.behavior.StylesDiffMap
12
+ import com.lynx.tasm.behavior.shadow.ShadowNode
13
+ import com.lynx.tasm.behavior.ui.LynxUI
14
+ import com.lynx.xelement.input.LynxEditTextView
15
+ import com.lynx.xelement.input.LynxUIInput
16
+ import com.lynx.xelement.input.LynxUIInputShadowNode
17
+ import com.lynx.xelement.input.LynxUITextArea
18
+ import com.lynx.xelement.input.LynxUITextAreaShadowNode
19
+
20
+ /** Draws the field's ASCII digits in the culture's digits (G9DigitsCore); the text itself is never changed. */
21
+ internal class G9DigitsTransformation(private val pairs: Pair<CharArray, CharArray>) : ReplacementTransformationMethod() {
22
+ val key: String = String(pairs.first) + String(pairs.second)
23
+
24
+ override fun getOriginal(): CharArray = pairs.first
25
+
26
+ override fun getReplacement(): CharArray = pairs.second
27
+ }
28
+
29
+ /** The digits state of one field: the props it was given and the transformation it applies. */
30
+ internal class G9FieldDigits(private val singleLine: Boolean) {
31
+ var system: String? = null
32
+ var decimalSeparator: String? = null
33
+
34
+ /**
35
+ * Puts the digit transformation on `view` after xelement-input's own props ran (it sets SingleLine / Password there).
36
+ * A password keeps its mask; a latin field gets back what xelement-input would have set.
37
+ */
38
+ fun apply(view: LynxEditTextView) {
39
+ val current: TransformationMethod? = view.transformationMethod
40
+ if (current is PasswordTransformationMethod) return
41
+ val pairs = G9DigitsCore.pairs(system, decimalSeparator, singleLine)
42
+ val wanted: TransformationMethod? =
43
+ when {
44
+ pairs != null -> {
45
+ if (current is G9DigitsTransformation && current.key == G9DigitsTransformation(pairs).key) return
46
+ G9DigitsTransformation(pairs)
47
+ }
48
+ current is G9DigitsTransformation -> if (singleLine) SingleLineTransformationMethod.getInstance() else null
49
+ else -> return
50
+ }
51
+ val start = view.selectionStart
52
+ val end = view.selectionEnd
53
+ view.transformationMethod = wanted
54
+ val length = view.text?.length ?: 0
55
+ if (start in 0..length && end in 0..length) view.setSelection(start, end)
56
+ }
57
+ }
58
+
59
+ /** xelement-input's `<input>` with `g9-digits` / `g9-decimal-separator` (`@g9tm/lynxcontrols` G9TextEntry sets them). */
60
+ class G9UIInput(context: LynxContext) : LynxUIInput(context) {
61
+ private val digits = G9FieldDigits(singleLine = true)
62
+
63
+ @LynxProp(name = "g9-digits")
64
+ fun setG9Digits(value: String?) {
65
+ digits.system = value
66
+ }
67
+
68
+ @LynxProp(name = "g9-decimal-separator")
69
+ fun setG9DecimalSeparator(value: String?) {
70
+ digits.decimalSeparator = value
71
+ }
72
+
73
+ override fun afterPropsUpdated(props: StylesDiffMap?) {
74
+ super.afterPropsUpdated(props)
75
+ digits.apply(view)
76
+ }
77
+ }
78
+
79
+ /** xelement-input's `<textarea>` with the same digit props (G9Editor). */
80
+ class G9UITextArea(context: LynxContext) : LynxUITextArea(context) {
81
+ private val digits = G9FieldDigits(singleLine = false)
82
+
83
+ @LynxProp(name = "g9-digits")
84
+ fun setG9Digits(value: String?) {
85
+ digits.system = value
86
+ }
87
+
88
+ @LynxProp(name = "g9-decimal-separator")
89
+ fun setG9DecimalSeparator(value: String?) {
90
+ digits.decimalSeparator = value
91
+ }
92
+
93
+ override fun afterPropsUpdated(props: StylesDiffMap?) {
94
+ super.afterPropsUpdated(props)
95
+ digits.apply(view)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * `input` and `textarea` for a host that registers xelement-input itself (an unregistered tag breaks the page, so a
101
+ * host registers exactly one of each): use these instead of `LynxUIInput` / `LynxUITextArea` and the fields draw the
102
+ * culture's digits. The host must still depend on `org.lynxsdk.lynx:xelement-input`.
103
+ */
104
+ object G9InputBehaviors {
105
+ fun create(): List<Behavior> =
106
+ listOf(
107
+ object : Behavior("input", false, false, false) {
108
+ override fun createUI(context: LynxContext): LynxUI<*> = G9UIInput(context)
109
+
110
+ override fun createShadowNode(): ShadowNode = LynxUIInputShadowNode()
111
+ },
112
+ object : Behavior("textarea", false, false, false) {
113
+ override fun createUI(context: LynxContext): LynxUI<*> = G9UITextArea(context)
114
+
115
+ override fun createShadowNode(): ShadowNode = LynxUITextAreaShadowNode()
116
+ },
117
+ )
118
+ }
@@ -0,0 +1,56 @@
1
+ package com.g9lynx.g9native.input.core
2
+
3
+ /**
4
+ * What a field DRAWS for the ASCII digits it holds (MAUI: the FaNum font draws «15» as «۱۵»; AgriPad owner 2026-11-02:
5
+ * «Lynx shows Persian too, but what is recorded is ASCII»). Pure, so the JVM suite tests it: the native input applies the
6
+ * pairs as a `ReplacementTransformationMethod`, which changes only what is drawn — the text, the value events and the
7
+ * selection keep the ASCII characters one for one.
8
+ */
9
+ object G9DigitsCore {
10
+ /** `g9-digits` values (the culture's numbering system, foundation `G9NumberingSystem`). */
11
+ const val LATIN = "latin"
12
+ const val PERSIAN = "persian"
13
+ const val ARABIC_INDIC = "arabicIndic"
14
+
15
+ /** The zero of `system`, or `null` when the field draws ASCII (latin, empty, unknown). */
16
+ fun zeroOf(system: String?): Char? =
17
+ when (system) {
18
+ PERSIAN -> '۰'
19
+ ARABIC_INDIC -> '٠'
20
+ else -> null
21
+ }
22
+
23
+ /**
24
+ * The characters to replace and their replacements (same length), or `null` when nothing is replaced.
25
+ * `singleLine` keeps what `SingleLineTransformationMethod` does (newline → space, CR → U+FEFF), since this method
26
+ * replaces it on a single-line input; `decimalSeparator` (one character, e.g. «٫») is drawn for the ASCII '.'.
27
+ */
28
+ fun pairs(system: String?, decimalSeparator: String?, singleLine: Boolean): Pair<CharArray, CharArray>? {
29
+ val zero = zeroOf(system) ?: return null
30
+ val from = StringBuilder("0123456789")
31
+ val to = StringBuilder()
32
+ for (d in 0..9) to.append(zero + d)
33
+ val separator = decimalSeparator?.takeIf { it.length == 1 && it != "." }
34
+ if (separator != null) {
35
+ from.append('.')
36
+ to.append(separator)
37
+ }
38
+ if (singleLine) {
39
+ from.append('\n').append('\r')
40
+ to.append(' ').append('')
41
+ }
42
+ return from.toString().toCharArray() to to.toString().toCharArray()
43
+ }
44
+
45
+ /** What `pairs` draws for `text` (the tests' view of the transformation). */
46
+ fun draw(text: String, pairs: Pair<CharArray, CharArray>?): String {
47
+ if (pairs == null) return text
48
+ val (from, to) = pairs
49
+ val out = StringBuilder(text.length)
50
+ for (c in text) {
51
+ val i = from.indexOf(c)
52
+ out.append(if (i >= 0) to[i] else c)
53
+ }
54
+ return out.toString()
55
+ }
56
+ }