@glassly/bluetooth-sdk 0.1.0-dev.112 → 0.1.0-dev.114

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.
Files changed (28) hide show
  1. package/android/src/main/assets/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  2. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +24 -2
  3. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +7 -0
  4. package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +198 -0
  5. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +3 -3
  6. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +9 -1
  7. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +3 -2
  8. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +79 -21
  9. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +2 -2
  10. package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
  11. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +37 -3
  12. package/build/generated/releaseMetadata.js +3 -3
  13. package/build/generated/releaseMetadata.js.map +1 -1
  14. package/ios/Source/BluetoothSDK.swift +1 -0
  15. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  16. package/ios/Source/DeviceManager.swift +27 -2
  17. package/ios/Source/DeviceStore.swift +7 -0
  18. package/ios/Source/DisplayPacer.swift +179 -0
  19. package/ios/Source/GeneratedReleaseMetadata.swift +3 -3
  20. package/ios/Source/Resources/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  21. package/ios/Source/sgcs/G2.swift +8 -1
  22. package/ios/Source/sgcs/G2CfwRenderer.swift +5 -3
  23. package/ios/Source/sgcs/G2CfwScene.swift +67 -19
  24. package/ios/Source/sgcs/G2Flasher.swift +2 -2
  25. package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
  26. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +46 -2
  27. package/package.json +1 -1
  28. package/src/generated/releaseMetadata.ts +3 -3
@@ -703,6 +703,17 @@ class DeviceManager {
703
703
  // native re-dispatch coherent (nav exit re-applies a complete scene, not
704
704
  // whatever element happened to arrive last).
705
705
  private var sceneState: SceneFrame? = null
706
+
707
+ /** Paces scene frames and legacy layouts to display_max_fps; clears bypass it. */
708
+ private val pacer =
709
+ DisplayPacer(dispatchScene = { dispatchSceneFrame(it) }, sendLegacy = { sendCurrentState() }).also {
710
+ it.setMaxFps((DeviceStore.store.get("bluetooth", "display_max_fps") as? Number)?.toInt() ?: DisplayPacer.DEFAULT_FPS)
711
+ }
712
+
713
+ fun setDisplayMaxFps(fps: Int) = pacer.setMaxFps(fps)
714
+
715
+ /** Drop paced display work (screen off): the retained state repaints on re-enable. */
716
+ fun cancelPendingDisplay() = pacer.cancel()
706
717
  // MARK: - End Unique
707
718
 
708
719
  // MARK: - Voice Data Handling
@@ -1208,6 +1219,7 @@ class DeviceManager {
1208
1219
  if (sgc != null && sgc?.type != wearable) {
1209
1220
  Bridge.log("MAN: Manager already initialized, cleaning up previous sgc")
1210
1221
  Bridge.log("MAN: Cleaning up previous sgc type: ${sgc?.type}")
1222
+ pacer.cancel()
1211
1223
  sgc?.cleanup()
1212
1224
  sgc = null
1213
1225
  resetSystemTimeSync()
@@ -1580,6 +1592,7 @@ class DeviceManager {
1580
1592
 
1581
1593
  fun clearDisplay() {
1582
1594
  Bridge.log("MAN: Clearing Display")
1595
+ pacer.cancel()
1583
1596
  sgc?.clearDisplay()
1584
1597
  }
1585
1598
 
@@ -1647,7 +1660,11 @@ class DeviceManager {
1647
1660
  }
1648
1661
 
1649
1662
  viewState = newViewState
1650
- sendCurrentState()
1663
+ if (layoutType == "clear_view") {
1664
+ pacer.flushLegacy()
1665
+ } else {
1666
+ pacer.submitLegacy()
1667
+ }
1651
1668
  }
1652
1669
 
1653
1670
  /** Parse + store a scene frame, then dispatch it. */
@@ -1682,7 +1699,11 @@ class DeviceManager {
1682
1699
  frame.copy(replay = true, elements = frame.elements.map { it.copy(change = "created") })
1683
1700
  viewState = ViewState(" ", " ", " ", "scene", " ", null, null)
1684
1701
 
1685
- dispatchSceneFrame(frame)
1702
+ if (frame.elements.isEmpty()) {
1703
+ pacer.flushScene(frame)
1704
+ } else {
1705
+ pacer.submitScene(frame)
1706
+ }
1686
1707
  }
1687
1708
 
1688
1709
  /** Guarded scene dispatch — mirrors sendCurrentState's send conditions. */
@@ -2306,6 +2327,7 @@ class DeviceManager {
2306
2327
  }
2307
2328
 
2308
2329
  fun disconnect(clearPendingIdentity: Boolean = true) {
2330
+ pacer.cancel()
2309
2331
  sgc?.clearDisplay()
2310
2332
  sgc?.disconnect()
2311
2333
  sgc = null // Clear the SGC reference after disconnect
@@ -132,6 +132,7 @@ object DeviceStore {
132
132
  store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
133
133
  store.set("bluetooth", "loudness_gate_enabled", BluetoothSdkDefaults.LOUDNESS_GATE_ENABLED)
134
134
  store.set("bluetooth", "screen_disabled", false)
135
+ store.set("bluetooth", "display_max_fps", DisplayPacer.DEFAULT_FPS)
135
136
  store.set("bluetooth", "button_photo_size", "max")
136
137
  store.set("bluetooth", "button_max_recording_time", 10)
137
138
  store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roi_position" to 0))
@@ -241,6 +242,11 @@ object DeviceStore {
241
242
  }
242
243
  }
243
244
  }
245
+ "bluetooth" to "display_max_fps" -> {
246
+ (value as? Number)?.let { fps ->
247
+ DeviceManager.getInstance().setDisplayMaxFps(fps.toInt())
248
+ }
249
+ }
244
250
  "bluetooth" to "dashboard_height" -> {
245
251
  scheduleDashboardHeightToGlasses()
246
252
  }
@@ -294,6 +300,7 @@ object DeviceStore {
294
300
  "bluetooth" to "screen_disabled" -> {
295
301
  (value as? Boolean)?.let { disabled ->
296
302
  if (disabled) {
303
+ DeviceManager.getInstance().cancelPendingDisplay()
297
304
  DeviceManager.getInstance().sgc?.exit()
298
305
  } else {
299
306
  DeviceManager.getInstance().sgc?.clearDisplay()
@@ -0,0 +1,198 @@
1
+ package com.glassly.bluetoothsdk
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import com.glassly.bluetoothsdk.sgcs.SceneFrame
6
+
7
+ /**
8
+ * Paces display updates leaving the phone to at most [setMaxFps] per second.
9
+ *
10
+ * Updates that arrive inside the current slot are held and sent at the next
11
+ * one, last-wins: a newer scene frame replaces a pending one (with the pending
12
+ * frame's per-element annotations folded in so per-element devices never miss
13
+ * a change), a legacy layout replaces a pending scene and vice versa. Clears
14
+ * ([flushScene] with an empty frame, [flushLegacy]) bypass the pacer and drop
15
+ * pending work.
16
+ *
17
+ * Safe to call from any thread; the trailing send runs on the scheduler's
18
+ * thread (main looper by default). Sinks are always invoked outside the lock.
19
+ */
20
+ class DisplayPacer(
21
+ private val dispatchScene: (SceneFrame) -> Unit,
22
+ private val sendLegacy: () -> Unit,
23
+ private val clock: () -> Long = { System.nanoTime() / 1_000_000 },
24
+ private val scheduler: Scheduler = MainScheduler(),
25
+ ) {
26
+ /** Runs [block] after [delayMs]; the returned function cancels it. */
27
+ fun interface Scheduler {
28
+ fun schedule(delayMs: Long, block: () -> Unit): () -> Unit
29
+ }
30
+
31
+ private class MainScheduler : Scheduler {
32
+ private val handler = Handler(Looper.getMainLooper())
33
+
34
+ override fun schedule(delayMs: Long, block: () -> Unit): () -> Unit {
35
+ val runnable = Runnable { block() }
36
+ handler.postDelayed(runnable, delayMs)
37
+ return { handler.removeCallbacks(runnable) }
38
+ }
39
+ }
40
+
41
+ companion object {
42
+ const val MIN_FPS = 2
43
+ const val MAX_FPS = 20
44
+ const val DEFAULT_FPS = 8
45
+
46
+ fun intervalFor(fps: Int): Long = 1000L / fps.coerceIn(MIN_FPS, MAX_FPS)
47
+
48
+ /**
49
+ * Fold pending frame [previous] into [next] (both unsent). The device
50
+ * only ever sees the merged frame, so an element the pending frame
51
+ * changed must not read as "unchanged" in the merged one, and ids the
52
+ * pending frame removed must still be removed unless [next] re-creates
53
+ * them. A different app is already a from-scratch replay.
54
+ */
55
+ fun merge(previous: SceneFrame?, next: SceneFrame): SceneFrame {
56
+ if (previous == null || previous.appId != next.appId) return next
57
+ val pending = previous.elements.associateBy { it.id }
58
+ val elements =
59
+ next.elements.map { el ->
60
+ val prior = pending[el.id] ?: return@map el
61
+ when {
62
+ prior.change == "created" -> el.copy(change = "created")
63
+ prior.change != "unchanged" && el.change == "unchanged" -> el.copy(change = "updated")
64
+ else -> el
65
+ }
66
+ }
67
+ val painted = next.elements.mapTo(HashSet()) { it.id }
68
+ val removed = (previous.removed + next.removed).distinct().filter { it !in painted }
69
+ return next.copy(replay = previous.replay || next.replay, elements = elements, removed = removed)
70
+ }
71
+ }
72
+
73
+ private val lock = Any()
74
+ private var intervalMs: Long = intervalFor(DEFAULT_FPS)
75
+ private var lastSendAt: Long = Long.MIN_VALUE / 2
76
+ private var pendingScene: SceneFrame? = null
77
+ private var pendingLegacy = false
78
+ private var cancelTimer: (() -> Unit)? = null
79
+
80
+ fun setMaxFps(fps: Int) {
81
+ var send: (() -> Unit)? = null
82
+ synchronized(lock) {
83
+ intervalMs = intervalFor(fps)
84
+ if (!hasPending()) return
85
+ stopTimer()
86
+ send = armOrTake(clock())
87
+ }
88
+ send?.invoke()
89
+ }
90
+
91
+ /** Send [frame] at the next slot; replaces any pending update. */
92
+ fun submitScene(frame: SceneFrame) {
93
+ var send: (() -> Unit)? = null
94
+ synchronized(lock) {
95
+ val now = clock()
96
+ val merged = merge(pendingScene, frame)
97
+ pendingScene = merged
98
+ pendingLegacy = false
99
+ if (now - lastSendAt >= intervalMs) {
100
+ stopTimer()
101
+ send = take(now)
102
+ } else if (cancelTimer == null) {
103
+ armTimer(now)
104
+ }
105
+ }
106
+ send?.invoke()
107
+ }
108
+
109
+ /** Send the current legacy view state at the next slot; replaces any pending update. */
110
+ fun submitLegacy() {
111
+ var send: (() -> Unit)? = null
112
+ synchronized(lock) {
113
+ val now = clock()
114
+ pendingScene = null
115
+ pendingLegacy = true
116
+ if (now - lastSendAt >= intervalMs) {
117
+ stopTimer()
118
+ send = take(now)
119
+ } else if (cancelTimer == null) {
120
+ armTimer(now)
121
+ }
122
+ }
123
+ send?.invoke()
124
+ }
125
+
126
+ /** Immediate send for an empty (clearing) frame; pending removes ride along. */
127
+ fun flushScene(frame: SceneFrame) {
128
+ val merged: SceneFrame
129
+ synchronized(lock) {
130
+ stopTimer()
131
+ merged = merge(pendingScene, frame)
132
+ pendingScene = null
133
+ pendingLegacy = false
134
+ lastSendAt = clock()
135
+ }
136
+ dispatchScene(merged)
137
+ }
138
+
139
+ /** Immediate legacy send (clear_view); drops any pending scene. */
140
+ fun flushLegacy() {
141
+ synchronized(lock) {
142
+ stopTimer()
143
+ pendingScene = null
144
+ pendingLegacy = false
145
+ lastSendAt = clock()
146
+ }
147
+ sendLegacy()
148
+ }
149
+
150
+ /** Drop pending work without sending (disconnect, screen off, clearDisplay). */
151
+ fun cancel() {
152
+ synchronized(lock) {
153
+ stopTimer()
154
+ pendingScene = null
155
+ pendingLegacy = false
156
+ }
157
+ }
158
+
159
+ private fun hasPending() = pendingScene != null || pendingLegacy
160
+
161
+ private fun stopTimer() {
162
+ cancelTimer?.invoke()
163
+ cancelTimer = null
164
+ }
165
+
166
+ /** Lock held. Either sends now (returns the send) or arms the slot timer. */
167
+ private fun armOrTake(now: Long): (() -> Unit)? {
168
+ if (now - lastSendAt >= intervalMs) return take(now)
169
+ armTimer(now)
170
+ return null
171
+ }
172
+
173
+ /** Lock held. Consume pending work into a send closure and stamp the slot. */
174
+ private fun take(now: Long): (() -> Unit)? {
175
+ val scene = pendingScene
176
+ val legacy = pendingLegacy
177
+ pendingScene = null
178
+ pendingLegacy = false
179
+ if (scene == null && !legacy) return null
180
+ lastSendAt = now
181
+ return if (scene != null) ({ dispatchScene(scene) }) else ({ sendLegacy() })
182
+ }
183
+
184
+ /** Lock held. Arm the single trailing timer for the next slot. */
185
+ private fun armTimer(now: Long) {
186
+ val delay = (lastSendAt + intervalMs - now).coerceAtLeast(0)
187
+ cancelTimer = scheduler.schedule(delay) { onTimer() }
188
+ }
189
+
190
+ private fun onTimer() {
191
+ var send: (() -> Unit)? = null
192
+ synchronized(lock) {
193
+ cancelTimer = null
194
+ send = take(clock())
195
+ }
196
+ send?.invoke()
197
+ }
198
+ }
@@ -3,9 +3,9 @@ package com.glassly.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "0.1.0"
6
- const val RELEASE_IDENTITY: String = "0.1.0-dev.112"
7
- const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.112"
8
- const val SOURCE_COMMIT: String = "385c3de9054122bf005790a6b1faf04d3117d512"
6
+ const val RELEASE_IDENTITY: String = "0.1.0-dev.114"
7
+ const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.114"
8
+ const val SOURCE_COMMIT: String = "17263133b35c04f044ba0d3acd9f80efc81999d4"
9
9
  const val OTA_MANIFEST_URL: String = ""
10
10
  const val OTA_MANIFEST_SHA256: String = ""
11
11
  }
@@ -1578,6 +1578,13 @@ class G2 : SGCManager() {
1578
1578
  supportsCfwSceneRendering &&
1579
1579
  listOf("scene17", "shapes16", "anim18", "texcache12", "teximg13", "font15").all { it in cfwFeatures }
1580
1580
 
1581
+ /**
1582
+ * TEXT_INLINE records (contract revision 19; no token, the capability
1583
+ * response must fit one BLE frame): default text drawn by the glasses' font.
1584
+ */
1585
+ private val supportsCfwInlineText: Boolean
1586
+ get() = cfwProtocolVersion >= 19
1587
+
1581
1588
  /** The on-glass slot table must be re-SET on the next vector frame. */
1582
1589
  private var cfwSceneInvalid = true
1583
1590
 
@@ -3104,9 +3111,10 @@ class G2 : SGCManager() {
3104
3111
  }
3105
3112
  if (cfwLastBoxes != boxes) cfwSceneInvalid = true
3106
3113
  val invalidate = cfwSceneInvalid
3114
+ val inlineText = supportsCfwInlineText
3107
3115
  val encoded =
3108
3116
  kotlinx.coroutines.withContext(Dispatchers.Default) {
3109
- G2CfwRenderer.encodeVector(frame, boxes, cfwCacheGeneration, invalidate)
3117
+ G2CfwRenderer.encodeVector(frame, boxes, cfwCacheGeneration, invalidate, inlineText)
3110
3118
  }
3111
3119
  if (encoded == null) {
3112
3120
  Bridge.log("G2: vector scene does not fit; using the raster path for this frame")
@@ -87,6 +87,7 @@ internal object G2CfwRenderer {
87
87
  boxes: LensBoxes,
88
88
  cacheGeneration: Int,
89
89
  invalidate: Boolean,
90
+ inlineText: Boolean,
90
91
  ): G2CfwScene.Encoded? {
91
92
  if (textureCache.generation != cacheGeneration || textureCache.exhausted) {
92
93
  textureCache.reset(cacheGeneration)
@@ -103,11 +104,11 @@ internal object G2CfwRenderer {
103
104
  h,
104
105
  )
105
106
  }
106
- scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne)?.let { return it }
107
+ scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne, inlineText)?.let { return it }
107
108
  // Out of texture cache mid-frame: start a fresh cache and repack once.
108
109
  textureCache.reset(cacheGeneration)
109
110
  scene.reset(cacheGeneration)
110
- return scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne)
111
+ return scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne, inlineText)
111
112
  }
112
113
  private val glyphRasterMemo = mutableMapOf<String, ByteArray>()
113
114
 
@@ -6,13 +6,14 @@ import java.io.ByteArrayOutputStream
6
6
  * Retained vector scene for the Glassly CFW (firmware modes 16-18).
7
7
  *
8
8
  * Every scene element becomes one or more firmware slots (paint order = slot
9
- * order): vector shapes map 1:1, builtin-font text becomes one TEXT slot per
10
- * line with its bytes in the texture cache, and everything else (proportional
11
- * text, images) is rasterized on the phone and drawn as cached IMAGE slots
12
- * tiled to the 255 px slot limit. Frames diff against the last committed
13
- * layout: unchanged slots cost nothing, geometry changes on an element with a
14
- * transition become firmware TWEENs, and anything else is re-SET. Slot indices
15
- * stay stable per element id; a reorder or an over-budget frame repacks.
9
+ * order): vector shapes map 1:1; default and builtin text becomes one inline
10
+ * TEXT slot per line drawn by the glasses' font (or a cache-backed TEXT slot on
11
+ * firmware without inline records); mono text and images are rasterized on
12
+ * the phone and drawn as cached IMAGE slots tiled to the 255 px slot limit.
13
+ * Frames diff against the last committed layout: unchanged slots cost nothing,
14
+ * geometry changes on an element with a transition become firmware TWEENs, and
15
+ * anything else is re-SET. Slot indices stay stable per element id; a reorder
16
+ * or an over-budget frame repacks.
16
17
  */
17
18
  internal class G2CfwScene {
18
19
  companion object {
@@ -20,7 +21,9 @@ internal class G2CfwScene {
20
21
  const val RECORD_BYTES = 20
21
22
  const val FRAME_PERIOD_MS = 33.0
22
23
  const val TEXT_LINE_HEIGHT = 27
24
+ const val TEXT_INSET = 2
23
25
  const val MAX_TEXT_BYTES = 64
26
+ const val MAX_INLINE_BYTES = 128
24
27
  const val MAX_TILE_SIZE = 255
25
28
  /** Mode-13/15 options: identity ramp, transparent source color 0. */
26
29
  const val TEXT_OPTIONS = 0x1F
@@ -42,6 +45,7 @@ internal class G2CfwScene {
42
45
  const val TYPE_IMAGE = 14
43
46
  const val TYPE_TEXT = 15
44
47
  const val TYPE_TEXT_CACHED = 16
48
+ const val TYPE_TEXT_INLINE = 17
45
49
 
46
50
  /** Geometry parameters a TWEEN may interpolate, per type. Texture offsets and lengths never tween. */
47
51
  fun tweenableMask(type: Int): Int =
@@ -52,6 +56,7 @@ internal class G2CfwScene {
52
56
  TYPE_TRI, TYPE_TRI_FILL, TYPE_BEZIER2 -> 0x3F
53
57
  TYPE_QUAD, TYPE_QUAD_FILL, TYPE_BEZIER3 -> 0xFF
54
58
  TYPE_IMAGE, TYPE_TEXT, TYPE_TEXT_CACHED -> 0x03
59
+ TYPE_TEXT_INLINE -> 0x0F
55
60
  else -> 0
56
61
  }
57
62
 
@@ -71,7 +76,7 @@ internal class G2CfwScene {
71
76
  }
72
77
 
73
78
  /** UTF-8 bytes of a line without firmware control bytes (1..31), cut at a codepoint boundary. */
74
- fun textBytes(line: String): ByteArray {
79
+ fun textBytes(line: String, limit: Int = MAX_TEXT_BYTES): ByteArray {
75
80
  val out = ByteArrayOutputStream()
76
81
  var i = 0
77
82
  while (i < line.length) {
@@ -79,7 +84,7 @@ internal class G2CfwScene {
79
84
  i += Character.charCount(cp)
80
85
  if (cp < 32) continue
81
86
  val encoded = String(Character.toChars(cp)).toByteArray(Charsets.UTF_8)
82
- if (out.size() + encoded.size > MAX_TEXT_BYTES) break
87
+ if (out.size() + encoded.size > limit) break
83
88
  out.write(encoded)
84
89
  }
85
90
  return out.toByteArray()
@@ -96,7 +101,10 @@ internal class G2CfwScene {
96
101
  out.write((value ushr 8) and 0xFF)
97
102
  }
98
103
 
99
- /** `[0][slot][type][flags=visible][color][width][p0..p7 LE]` */
104
+ /**
105
+ * `[0][slot][type][flags=visible][color][width][p0..p7 LE]`, or for TEXT_INLINE
106
+ * `[0][slot][17][1][color][width][x][y][w][h][len][bytes]`.
107
+ */
100
108
  fun setRecord(slot: Slot, index: Int): ByteArray {
101
109
  val out = ByteArrayOutputStream()
102
110
  out.write(0)
@@ -105,7 +113,13 @@ internal class G2CfwScene {
105
113
  out.write(1)
106
114
  out.write(slot.color)
107
115
  out.write(slot.width)
108
- for (value in slot.params) le(out, value)
116
+ val inline = slot.type == TYPE_TEXT_INLINE
117
+ for (k in 0 until (if (inline) 4 else 8)) le(out, slot.params[k])
118
+ if (inline) {
119
+ val bytes = slot.text.take(MAX_INLINE_BYTES).toByteArray()
120
+ out.write(bytes.size)
121
+ out.write(bytes)
122
+ }
109
123
  return out.toByteArray()
110
124
  }
111
125
 
@@ -114,7 +128,7 @@ internal class G2CfwScene {
114
128
  * change is not a pure geometry/stroke change of the same shape type.
115
129
  */
116
130
  fun tweenRecord(old: Slot, new: Slot, index: Int, frames: Int, curve: IntArray): ByteArray? {
117
- if (old.type != new.type) return null
131
+ if (old.type != new.type || !old.text.contentEquals(new.text)) return null
118
132
  val tweenable = tweenableMask(new.type)
119
133
  var mask = 0
120
134
  val values = mutableListOf<Int>()
@@ -126,7 +140,9 @@ internal class G2CfwScene {
126
140
  }
127
141
  val isTexture = new.type >= TYPE_IMAGE
128
142
  if (old.color != new.color) {
129
- if (isTexture) return null
143
+ // Inline text may fade (low nibble) as long as its option bits stay put.
144
+ val inlineFade = new.type == TYPE_TEXT_INLINE && (old.color and 0xF0) == (new.color and 0xF0)
145
+ if (isTexture && !inlineFade) return null
130
146
  mask = mask or 0x100
131
147
  values.add(new.color)
132
148
  }
@@ -147,15 +163,19 @@ internal class G2CfwScene {
147
163
  }
148
164
  }
149
165
 
150
- /** One firmware slot: type, 4-bit color (or options byte), stroke width, eight int16 parameters. */
151
- class Slot(val type: Int, val color: Int, val width: Int, params: List<Int>) {
166
+ /**
167
+ * One firmware slot: type, 4-bit color (or options byte), stroke width, eight
168
+ * int16 parameters, and the record's own bytes for TEXT_INLINE.
169
+ */
170
+ class Slot(val type: Int, val color: Int, val width: Int, params: List<Int>, val text: ByteArray = ByteArray(0)) {
152
171
  val params: IntArray = IntArray(8) { params.getOrElse(it) { 0 } }
153
172
 
154
173
  override fun equals(other: Any?): Boolean =
155
174
  other is Slot && other.type == type && other.color == color && other.width == width &&
156
- other.params.contentEquals(params)
175
+ other.params.contentEquals(params) && other.text.contentEquals(text)
157
176
 
158
- override fun hashCode(): Int = ((type * 31 + color) * 31 + width) * 31 + params.contentHashCode()
177
+ override fun hashCode(): Int =
178
+ (((type * 31 + color) * 31 + width) * 31 + params.contentHashCode()) * 31 + text.contentHashCode()
159
179
  }
160
180
 
161
181
  class ElementState(val id: String, val contentHash: String, val slots: List<Slot>, val indices: List<Int>)
@@ -189,11 +209,15 @@ internal class G2CfwScene {
189
209
  return Triple(clampCoord(element.x + element.w / 2 + ox), clampCoord(element.y + element.h / 2 + oy), r)
190
210
  }
191
211
 
192
- /** Slots for one element, or null when the texture cache cannot hold its content. */
212
+ /**
213
+ * Slots for one element, or null when the texture cache cannot hold its content.
214
+ * [inlineText] selects TEXT_INLINE records for default/builtin text.
215
+ */
193
216
  fun slotsFor(
194
217
  element: SceneElement,
195
218
  ox: Int,
196
219
  oy: Int,
220
+ inlineText: Boolean,
197
221
  cache: G2CfwTextureCache,
198
222
  rasterize: (SceneElement, Int, Int) -> ByteArray?,
199
223
  ): List<Slot>? {
@@ -245,13 +269,46 @@ internal class G2CfwScene {
245
269
  val params = (0 until n).flatMap { point(element, it, ox, oy).toList() }
246
270
  listOf(Slot(if (n == 3) TYPE_BEZIER2 else TYPE_BEZIER3, color, stroke(element), params))
247
271
  }
248
- "text" -> if (element.font == "builtin") builtinTextSlots(element, x, y, cache)
249
- else rasterSlots(element, x, y, cache, rasterize)
272
+ "text" ->
273
+ when {
274
+ element.font == "mono" -> rasterSlots(element, x, y, cache, rasterize)
275
+ inlineText -> inlineTextSlots(element, x, y)
276
+ element.font == "builtin" -> builtinTextSlots(element, x, y, cache)
277
+ else -> rasterSlots(element, x, y, cache, rasterize)
278
+ }
250
279
  "image" -> rasterSlots(element, x, y, cache, rasterize)
251
280
  else -> emptyList()
252
281
  }
253
282
  }
254
283
 
284
+ /** One TEXT_INLINE slot per line, drawn by the glasses' font at the phone-raster 2 px inset, clipped to the box. */
285
+ private fun inlineTextSlots(element: SceneElement, x: Int, y: Int): List<Slot> {
286
+ val out = mutableListOf<Slot>()
287
+ val w = element.w.coerceIn(0, 2047)
288
+ val h = element.h.coerceIn(0, 2047)
289
+ if (element.border > 0) {
290
+ out.add(Slot(TYPE_RECT, gray(element), stroke(element), listOf(x, y, w, h, element.radius.coerceIn(0, 2047))))
291
+ }
292
+ val options = 0x10 or gray(element)
293
+ val maxLines = maxOf(1, element.h / TEXT_LINE_HEIGHT)
294
+ val lines = (element.text ?: "").split("\n").take(maxLines)
295
+ for ((index, line) in lines.withIndex()) {
296
+ val bytes = textBytes(line, MAX_INLINE_BYTES)
297
+ if (bytes.isEmpty()) continue
298
+ val lineY = y + TEXT_INSET + index * TEXT_LINE_HEIGHT
299
+ val clipW = maxOf(0, element.w - TEXT_INSET).coerceAtMost(2047)
300
+ val clipH = maxOf(0, element.h - TEXT_INSET - index * TEXT_LINE_HEIGHT).coerceAtMost(2047)
301
+ out.add(
302
+ Slot(
303
+ TYPE_TEXT_INLINE, options, 0,
304
+ listOf(clampCoord(x + TEXT_INSET), clampCoord(lineY), clipW, clipH, bytes.size),
305
+ bytes,
306
+ ),
307
+ )
308
+ }
309
+ return out
310
+ }
311
+
255
312
  private fun builtinTextSlots(element: SceneElement, x: Int, y: Int, cache: G2CfwTextureCache): List<Slot>? {
256
313
  val out = mutableListOf<Slot>()
257
314
  if (element.border > 0) {
@@ -324,10 +381,11 @@ internal class G2CfwScene {
324
381
  originY: Int,
325
382
  cache: G2CfwTextureCache,
326
383
  rasterize: (SceneElement, Int, Int) -> ByteArray?,
384
+ inlineText: Boolean = false,
327
385
  ): Encoded? {
328
386
  val desired = mutableListOf<Desired>()
329
387
  for (element in frame.elements) {
330
- val slots = slotsFor(element, originX, originY, cache, rasterize) ?: return null
388
+ val slots = slotsFor(element, originX, originY, inlineText, cache, rasterize) ?: return null
331
389
  desired.add(
332
390
  Desired(element.id, element.contentHash, slots, transitionFrames(element.transitionMs), easingCurve(element.easing)),
333
391
  )
@@ -50,8 +50,8 @@ internal object G2FirmwareCatalog {
50
50
  displayName = "Glassly CFW",
51
51
  version = "2.2.9.22 CFW",
52
52
  fileName = "g2-glassly-cfw-2.2.9.22.evenota.bin",
53
- sha256 = "fe6051085507269c8007bc1245bfe8bd10d5c566540389ae976daaf753f536f9",
54
- size = 4_527_476,
53
+ sha256 = "0c5990320d9f48fdca9b539be801f8f231f530fba286353c247dbe7911283f63",
54
+ size = 4_528_170,
55
55
  ),
56
56
  )
57
57