rotor-framework 0.8.1 β†’ 0.8.2

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
@@ -96,7 +96,7 @@ You can find [🌱](./docs/ai/readme.opt.yaml) symbols in all documentation page
96
96
 
97
97
  ## πŸ“š Learn More
98
98
 
99
- ![Version](https://img.shields.io/badge/version-v0.8.1-blue?label=Documents%20TAG)
99
+ ![Version](https://img.shields.io/badge/version-v0.8.2-blue?label=Documents%20TAG)
100
100
 
101
101
  ### Framework Core
102
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rotor-framework",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Roku toolkit library providing a ViewBuilder, full UI lifecycle with focus handling and many core features, plus MVI-based state management.",
5
5
  "author": "BalΓ‘zs MolnΓ‘r",
6
6
  "license": "Apache-2.0",
@@ -4,7 +4,7 @@
4
4
  ' β–β–›β–€β–šβ––β–β–Œ β–β–Œ β–ˆ β–β–Œ β–β–Œβ–β–›β–€β–šβ–– β–β–›β–€β–€β–˜β–β–›β–€β–šβ––β–β–›β–€β–œβ–Œβ–β–Œ β–β–Œβ–β–›β–€β–€β–˜β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–›β–€β–šβ––β–β–›β–šβ––
5
5
  ' β–β–Œ β–β–Œβ–β–šβ–„β–žβ–˜ β–ˆ β–β–šβ–„β–žβ–˜β–β–Œ β–β–Œ β–β–Œ β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–™β–„β–„β––β–β–™β–ˆβ–Ÿβ–Œβ–β–šβ–„β–žβ–˜β–β–Œ β–β–Œβ–β–Œ β–β–Œ
6
6
  ' Rotor Frameworkβ„’
7
- ' Version 0.8.1
7
+ ' Version 0.8.2
8
8
  ' Β© 2025-2026 BalΓ‘zs MolnΓ‘r β€” Apache License 2.0
9
9
  ' =========================================================================
10
10
 
@@ -86,7 +86,7 @@ namespace Rotor
86
86
  class Framework
87
87
 
88
88
  name = "Rotor Framework"
89
- version = "0.8.1"
89
+ version = "0.8.2"
90
90
 
91
91
  config = {
92
92
  tasks: invalid, ' @array (optional)
@@ -295,12 +295,15 @@ namespace Rotor
295
295
  end function
296
296
 
297
297
  ' ---------------------------------------------------------------------
298
- ' getDispatcher - Gets dispatcher facade by ID
298
+ ' connectDispatcher - Creates a new dispatcher facade connection by ID
299
+ '
300
+ ' Each call creates a unique facade with its own listenerId and scope binding.
301
+ ' Use facade.release() when done to clean up listeners.
299
302
  '
300
303
  ' @param {string} dispatcherId - Dispatcher identifier
301
304
  ' @returns {object} Dispatcher facade instance
302
305
  '
303
- public function getDispatcher(dispatcherId as string) as object
306
+ public function connectDispatcher(dispatcherId as string) as object
304
307
  return m.dispatcherProvider.getFacade(dispatcherId, GetGlobalAA())
305
308
  end function
306
309
 
@@ -336,14 +339,15 @@ namespace Rotor
336
339
  ' ---------------------------------------------------------------------
337
340
  ' registerSourceObject - Not available on render thread
338
341
  '
339
- public sub registerSourceObject(objectId as string, dispatcherId as string, sourceObject as object, eventFilter = invalid as dynamic)
340
- throw "registerSourceObject() is only available on the task thread. Source objects require a task thread message port for event routing."
341
- end sub
342
+ public function registerSourceObject(typeName as string, dispatcherId as string, eventFilter = invalid as dynamic) as object
343
+ throw "registerSourceObject() is only available on the task thread."
344
+ return invalid
345
+ end function
342
346
 
343
347
  ' ---------------------------------------------------------------------
344
348
  ' unregisterSourceObject - Not available on render thread
345
349
  '
346
- public sub unregisterSourceObject(objectId as string)
350
+ public sub unregisterSourceObject(sourceObject as object, dispatcherId as string)
347
351
  throw "unregisterSourceObject() is only available on the task thread."
348
352
  end sub
349
353
 
@@ -4,7 +4,7 @@
4
4
  ' β–β–›β–€β–šβ––β–β–Œ β–β–Œ β–ˆ β–β–Œ β–β–Œβ–β–›β–€β–šβ–– β–β–›β–€β–€β–˜β–β–›β–€β–šβ––β–β–›β–€β–œβ–Œβ–β–Œ β–β–Œβ–β–›β–€β–€β–˜β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–›β–€β–šβ––β–β–›β–šβ––
5
5
  ' β–β–Œ β–β–Œβ–β–šβ–„β–žβ–˜ β–ˆ β–β–šβ–„β–žβ–˜β–β–Œ β–β–Œ β–β–Œ β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–™β–„β–„β––β–β–™β–ˆβ–Ÿβ–Œβ–β–šβ–„β–žβ–˜β–β–Œ β–β–Œβ–β–Œ β–β–Œ
6
6
  ' Rotor Frameworkβ„’
7
- ' Version 0.8.1
7
+ ' Version 0.8.2
8
8
  ' Β© 2025-2026 BalΓ‘zs MolnΓ‘r β€” Apache License 2.0
9
9
  ' =========================================================================
10
10
 
@@ -70,7 +70,7 @@ namespace Rotor
70
70
  class FrameworkTask
71
71
 
72
72
  name = "Rotor Framework"
73
- version = "0.8.1"
73
+ version = "0.8.2"
74
74
 
75
75
  config = {
76
76
  tasks: invalid, ' optional
@@ -88,7 +88,7 @@ namespace Rotor
88
88
  port as object
89
89
  sourceObjectRegistry = {} ' identity -> { dispatcherId, objectId, eventFilter }
90
90
  sourceObjectIdIndex = {} ' objectId -> identity (reverse index for unregistration)
91
- sourceObjectTypeRegistry = [] ' [{ dispatcherId, objectId, eventFilter }] - broadcast routing
91
+ sharedSourceObjects = {} ' typeName -> { sourceObject, subscribers: [{ dispatcherId, eventFilter }] }
92
92
  private _eventFilterFn = invalid as dynamic
93
93
  onTick as function
94
94
 
@@ -124,34 +124,40 @@ namespace Rotor
124
124
  ' =====================================================================
125
125
 
126
126
  ' ---------------------------------------------------------------------
127
- ' getDispatcher - Gets dispatcher facade by ID
127
+ ' connectDispatcher - Creates a new dispatcher facade connection by ID
128
+ '
129
+ ' Each call creates a unique facade with its own listenerId and scope binding.
130
+ ' Use facade.release() when done to clean up listeners.
128
131
  '
129
132
  ' @param {string} dispatcherId - Dispatcher identifier
130
133
  ' @returns {object} Dispatcher facade instance
131
134
  '
132
- public function getDispatcher(dispatcherId as string) as object
135
+ public function connectDispatcher(dispatcherId as string) as object
133
136
  return m.dispatcherProvider.getFacade(dispatcherId, GetGlobalAA())
134
137
  end function
135
138
 
136
139
  ' ---------------------------------------------------------------------
137
- ' registerSourceObject - Registers a source object for event routing
140
+ ' registerSourceObject - Creates and registers a source object for event routing
138
141
  '
139
- ' Generic registry for any Roku source object. Auto-detects routing mode:
140
- ' - Identity-based: If sourceObject implements ifSourceIdentity, routes via
141
- ' GetSourceIdentity() on the event (roUrlTransfer, roChannelStore).
142
- ' - Broadcast: If sourceObject does NOT implement ifSourceIdentity, broadcasts
143
- ' to all registered dispatchers (roDeviceInfo, roInput, roAppManager).
142
+ ' Creates a Roku source object by type name and auto-detects routing mode:
143
+ ' - Identity-based: If sourceObject implements GetIdentity, each call creates
144
+ ' a new instance with unique routing (roUrlTransfer, roChannelStore).
145
+ ' - Broadcast (singleton): If sourceObject does NOT implement GetIdentity,
146
+ ' first call creates the instance, subsequent calls return the shared instance
147
+ ' and add the dispatcher as a subscriber (roDeviceInfo, roInput, roAppManager).
144
148
  '
145
- ' @param {string} objectId - Unique identifier for this registration
149
+ ' @param {string} typeName - Roku object type name (e.g. "roUrlTransfer", "roDeviceInfo")
146
150
  ' @param {string} dispatcherId - Dispatcher ID that will handle events
147
- ' @param {object} sourceObject - The source object (SetMessagePort will be called)
148
151
  ' @param {function} eventFilter - Optional filter function. Receives msg, returns boolean.
152
+ ' @returns {object} The created (or shared) source object
149
153
  '
150
- public sub registerSourceObject(objectId as string, dispatcherId as string, sourceObject as object, eventFilter = invalid as dynamic)
151
- sourceObject.SetMessagePort(m.port)
154
+ public function registerSourceObject(typeName as string, dispatcherId as string, eventFilter = invalid as dynamic) as object
155
+ sourceObject = CreateObject(typeName)
152
156
  if FindMemberFunction(sourceObject, "GetIdentity") <> invalid
153
- ' Identity-based routing
157
+ ' Identity-based: unique per call
158
+ sourceObject.SetMessagePort(m.port)
154
159
  identity = sourceObject.GetIdentity().ToStr()
160
+ objectId = `${dispatcherId}_${identity}`
155
161
  m.sourceObjectRegistry[identity] = {
156
162
  dispatcherId: dispatcherId,
157
163
  objectId: objectId,
@@ -159,34 +165,64 @@ namespace Rotor
159
165
  }
160
166
  m.sourceObjectIdIndex[objectId] = identity
161
167
  else
162
- ' Broadcast routing
163
- m.sourceObjectTypeRegistry.push({
164
- dispatcherId: dispatcherId,
165
- objectId: objectId,
166
- eventFilter: eventFilter
167
- })
168
+ ' Broadcast: singleton per type
169
+ if m.sharedSourceObjects.DoesExist(typeName)
170
+ ' Existing β€” add subscriber, discard new object
171
+ m.sharedSourceObjects[typeName].subscribers.push({
172
+ dispatcherId: dispatcherId,
173
+ eventFilter: eventFilter
174
+ })
175
+ return m.sharedSourceObjects[typeName].sourceObject
176
+ else
177
+ ' First β€” store as shared
178
+ sourceObject.SetMessagePort(m.port)
179
+ m.sharedSourceObjects[typeName] = {
180
+ sourceObject: sourceObject,
181
+ subscribers: [{
182
+ dispatcherId: dispatcherId,
183
+ eventFilter: eventFilter
184
+ }]
185
+ }
186
+ end if
168
187
  end if
169
- end sub
188
+ return sourceObject
189
+ end function
170
190
 
171
191
  ' ---------------------------------------------------------------------
172
- ' unregisterSourceObject - Unregisters a port-based object by its objectId
192
+ ' unregisterSourceObject - Unregisters a source object
173
193
  '
174
- ' @param {string} objectId - The unique identifier used during registration
194
+ ' Identity-based objects: removes by identity from registry.
195
+ ' Broadcast objects: removes dispatcher subscriber, cleans up if no subscribers remain.
175
196
  '
176
- public sub unregisterSourceObject(objectId as string)
177
- ' Try identity-based registry
178
- if m.sourceObjectIdIndex.DoesExist(objectId)
179
- identity = m.sourceObjectIdIndex[objectId]
180
- m.sourceObjectRegistry.Delete(identity)
181
- m.sourceObjectIdIndex.Delete(objectId)
182
- return
183
- end if
184
- ' Try broadcast registry
185
- for i = m.sourceObjectTypeRegistry.count() - 1 to 0 step -1
186
- if m.sourceObjectTypeRegistry[i].objectId = objectId
187
- m.sourceObjectTypeRegistry.Delete(i)
197
+ ' @param {object} sourceObject - The source object to unregister
198
+ ' @param {string} dispatcherId - Dispatcher ID that owns this registration
199
+ '
200
+ public sub unregisterSourceObject(sourceObject as object, dispatcherId as string)
201
+ if FindMemberFunction(sourceObject, "GetIdentity") <> invalid
202
+ ' Identity-based: remove by identity
203
+ identity = sourceObject.GetIdentity().ToStr()
204
+ if m.sourceObjectRegistry.DoesExist(identity)
205
+ m.sourceObjectRegistry.Delete(identity)
206
+ end if
207
+ objectId = `${dispatcherId}_${identity}`
208
+ if m.sourceObjectIdIndex.DoesExist(objectId)
209
+ m.sourceObjectIdIndex.Delete(objectId)
210
+ end if
211
+ else
212
+ ' Broadcast: remove subscriber, cleanup if empty
213
+ typeName = type(sourceObject)
214
+ if m.sharedSourceObjects.DoesExist(typeName)
215
+ shared = m.sharedSourceObjects[typeName]
216
+ for i = shared.subscribers.count() - 1 to 0 step -1
217
+ if shared.subscribers[i].dispatcherId = dispatcherId
218
+ shared.subscribers.Delete(i)
219
+ end if
220
+ end for
221
+ if shared.subscribers.count() = 0
222
+ m.sharedSourceObjects.Delete(typeName)
223
+ end if
188
224
  end if
189
- end for
225
+ end if
190
226
  end sub
191
227
 
192
228
  ' ---------------------------------------------------------------------
@@ -300,22 +336,24 @@ namespace Rotor
300
336
  end try
301
337
  end if
302
338
 
303
- ' Broadcast to all non-identity registered dispatchers
339
+ ' Broadcast to shared source object subscribers
304
340
  if not routed
305
- for each entry in m.sourceObjectTypeRegistry
306
- ' Apply event filter if provided
307
- allowed = true
308
- if entry.eventFilter <> invalid
309
- m._eventFilterFn = entry.eventFilter
310
- allowed = m._eventFilterFn(msg)
311
- end if
341
+ for each typeName in m.sharedSourceObjects
342
+ shared = m.sharedSourceObjects[typeName]
343
+ for each subscriber in shared.subscribers
344
+ allowed = true
345
+ if subscriber.eventFilter <> invalid
346
+ m._eventFilterFn = subscriber.eventFilter
347
+ allowed = m._eventFilterFn(msg)
348
+ end if
312
349
 
313
- if allowed
314
- dispatcherInstance = m.dispatcherProvider.get(entry.dispatcherId)
315
- if dispatcherInstance <> invalid
316
- dispatcherInstance.onSourceEvent(msg)
350
+ if allowed
351
+ dispatcherInstance = m.dispatcherProvider.get(subscriber.dispatcherId)
352
+ if dispatcherInstance <> invalid
353
+ dispatcherInstance.onSourceEvent(msg)
354
+ end if
317
355
  end if
318
- end if
356
+ end for
319
357
  end for
320
358
  end if
321
359
  end if
@@ -385,7 +423,7 @@ namespace Rotor
385
423
  public sub destroy()
386
424
  m.sourceObjectRegistry.clear()
387
425
  m.sourceObjectIdIndex.clear()
388
- m.sourceObjectTypeRegistry.clear()
426
+ m.sharedSourceObjects.clear()
389
427
  m.dispatcherProvider.destroy()
390
428
 
391
429
  globalScope = GetGlobalAA()
@@ -27,7 +27,7 @@ namespace Rotor
27
27
  port as object ' Framework port for async operations
28
28
 
29
29
  ' Dispatcher integration
30
- getDispatcher as function ' Get dispatcher facade by ID
30
+ connectDispatcher as function ' Connect to dispatcher facade by ID
31
31
  getState as function ' Get dispatcher's model state
32
32
  dispatch as function ' Dispatch intent to owner dispatcher
33
33
  dispatchTo as function ' Dispatch intent to dispatcher by ID
@@ -49,8 +49,8 @@ namespace Rotor
49
49
  frameworkInstance = GetGlobalAA().rotor_framework_helper.frameworkInstance
50
50
  m.port = frameworkInstance.port
51
51
 
52
- ' Inject getDispatcher method
53
- m.getDispatcher = function(dispatcherId as string) as object
52
+ ' Inject connectDispatcher method
53
+ m.connectDispatcher = function(dispatcherId as string) as object
54
54
  return GetGlobalAA().rotor_framework_helper.frameworkInstance.dispatcherProvider.getFacade(dispatcherId, m)
55
55
  end function
56
56
 
@@ -66,13 +66,13 @@ namespace Rotor
66
66
 
67
67
  ' Inject dispatchTo method for dispatching intents to other dispatchers
68
68
  m.dispatchTo = sub(dispatcherId as string, dispatchObject as object)
69
- dispatcherFacade = m.getDispatcher(dispatcherId)
69
+ dispatcherFacade = m.connectDispatcher(dispatcherId)
70
70
  dispatcherFacade.dispatch(dispatchObject)
71
71
  end sub
72
72
 
73
73
  ' Inject getStateFrom method for accessing state from other dispatchers
74
74
  m.getStateFrom = function(dispatcherId as string, mapStateToProps = invalid as dynamic)
75
- dispatcherFacade = m.getDispatcher(dispatcherId)
75
+ dispatcherFacade = m.connectDispatcher(dispatcherId)
76
76
  return dispatcherFacade.getState(mapStateToProps)
77
77
  end function
78
78
  end sub
@@ -191,23 +191,21 @@ namespace Rotor
191
191
  ' =============================================================
192
192
 
193
193
  ' ---------------------------------------------------------------------
194
- ' registerSourceObject - Registers a source object with the framework
194
+ ' registerSourceObject - Creates and registers a source object with the framework
195
195
  '
196
- ' Helper method for registering any Roku source object (roUrlTransfer,
197
- ' roDeviceInfo, roChannelStore, etc.). The framework will call
198
- ' SetMessagePort and route events to this reducer's onSourceEvent.
199
- ' Routing mode is auto-detected: identity-based if sourceObject has GetIdentity(),
200
- ' broadcast otherwise.
196
+ ' Creates a Roku source object by type name. The framework manages object
197
+ ' creation, message port assignment, and event routing.
198
+ ' - Identity-based types (roUrlTransfer, roChannelStore): new instance per call
199
+ ' - Broadcast types (roDeviceInfo, roInput): singleton, shared across dispatchers
201
200
  '
202
- ' @param {object} sourceObject - The source object
201
+ ' @param {string} typeName - Roku object type (e.g. "roUrlTransfer", "roDeviceInfo")
203
202
  ' @param {function} eventFilter - Optional filter function. Receives msg, returns boolean.
203
+ ' @returns {object} The created (or shared) source object
204
204
  '
205
- sub registerSourceObject(sourceObject as object, eventFilter = invalid as dynamic)
205
+ function registerSourceObject(typeName as string, eventFilter = invalid as dynamic) as object
206
206
  frameworkInstance = GetGlobalAA().rotor_framework_helper.frameworkInstance
207
-
208
- objectId = m.getSourceObjectId(sourceObject)
209
- frameworkInstance.registerSourceObject(objectId, m.dispatcherId, sourceObject, eventFilter)
210
- end sub
207
+ return frameworkInstance.registerSourceObject(typeName, m.dispatcherId, eventFilter)
208
+ end function
211
209
 
212
210
  ' ---------------------------------------------------------------------
213
211
  ' unregisterSourceObject - Unregisters a source object from the framework
@@ -216,31 +214,9 @@ namespace Rotor
216
214
  '
217
215
  sub unregisterSourceObject(sourceObject as object)
218
216
  frameworkInstance = GetGlobalAA().rotor_framework_helper.frameworkInstance
219
- objectId = m.getSourceObjectId(sourceObject)
220
- frameworkInstance.unregisterSourceObject(objectId)
217
+ frameworkInstance.unregisterSourceObject(sourceObject, m.dispatcherId)
221
218
  end sub
222
219
 
223
- ' =============================================================
224
- ' ASYNC CALLBACK
225
- ' =============================================================
226
-
227
- ' ---------------------------------------------------------------------
228
- ' getSourceObjectId - Generates unique objectId for a source object
229
- '
230
- ' Identity-based objects (roUrlTransfer, etc.): dispatcherId + GetIdentity()
231
- ' Broadcast objects (roDeviceInfo, etc.): dispatcherId + type name
232
- '
233
- ' @param {object} sourceObject - The source object
234
- ' @returns {string} Unique objectId
235
- '
236
- private function getSourceObjectId(sourceObject as object) as string
237
- if FindMemberFunction(sourceObject, "GetIdentity") <> invalid
238
- return `${m.dispatcherId}_${sourceObject.GetIdentity()}`
239
- else
240
- return `${m.dispatcherId}_${type(sourceObject)}`
241
- end if
242
- end function
243
-
244
220
  ' ---------------------------------------------------------------------
245
221
  ' onSourceEvent - Callback for source object events
246
222
  '
@@ -73,7 +73,7 @@ namespace Rotor
73
73
 
74
74
  ' Framework Integration
75
75
  getFrameworkInstance as function ' Get framework instance
76
- getDispatcher as function ' Get dispatcher facade by ID
76
+ connectDispatcher as function ' Connect to dispatcher facade by ID
77
77
  dispatchTo as function ' Dispatch intent to dispatcher by ID
78
78
  getStateFrom as function ' Get state from dispatcher by ID
79
79
  animator as function ' Get animator factory
@@ -90,6 +90,7 @@ namespace Rotor
90
90
  ' Focus Plugin Methods (from FocusPlugin.bs)
91
91
  optional enableFocusNavigation as function ' Enable focus navigation
92
92
  optional isFocusNavigationEnabled as function ' Check if focus enabled
93
+ optional isFocused as function ' Check if widget is focused
93
94
  optional setFocus as function ' Set focus on widget
94
95
  optional getFocusedWidget as function ' Get currently focused widget
95
96
  optional proceedLongPress as function ' Trigger long press
@@ -136,20 +136,20 @@ namespace Rotor.ViewBuilder
136
136
  m.getFrameworkInstance().builder.erase(payloads, shouldSkipNodePool, parentHID)
137
137
  end sub
138
138
 
139
- ' getDispatcher - Gets dispatcher facade by ID *'
140
- widget.getDispatcher = function(dispatcherId as string) as object
139
+ ' connectDispatcher - Creates a new dispatcher facade connection by ID *'
140
+ widget.connectDispatcher = function(dispatcherId as string) as object
141
141
  return m.getFrameworkInstance().dispatcherProvider.getFacade(dispatcherId, m)
142
142
  end function
143
143
 
144
144
  ' Dispatch shortcut - Dispatches an event via specific dispatcher'
145
145
  widget.dispatchTo = sub(dispatcherId as string, dispatchObject as object)
146
- dispatcherFaced = m.getDispatcher(dispatcherId)
146
+ dispatcherFaced = m.connectDispatcher(dispatcherId)
147
147
  dispatcherFaced.dispatch(dispatchObject)
148
148
  end sub
149
149
 
150
150
  ' Listen shortcut - Listen to specific dispatcher'
151
151
  widget.getStateFrom = function(dispatcherId as string, mapStateToProps = invalid as dynamic)
152
- dispatcherFaced = m.getDispatcher(dispatcherId)
152
+ dispatcherFaced = m.connectDispatcher(dispatcherId)
153
153
  return dispatcherFaced.getState(mapStateToProps)
154
154
  end function
155
155
 
@@ -60,9 +60,12 @@ namespace Rotor
60
60
  end sub
61
61
 
62
62
  ' ---------------------------------------------------------------------
63
- ' destroy - Cleans up the dispatcher facade by removing all listeners and clearing references
63
+ ' release - Releases this dispatcher facade by removing all listeners and clearing references
64
64
  '
65
- public sub destroy()
65
+ ' Note: This does not destroy the underlying dispatcher β€” only releases
66
+ ' the facade's connection (listenerId + scope binding). One-way operation.
67
+ '
68
+ public sub release()
66
69
  m.removeAllListenersByListenerId()
67
70
  m.listenerScope = invalid
68
71
  m.dispatcherInstance = invalid
@@ -71,7 +71,7 @@ namespace Rotor
71
71
  if dispatcherFacades.Count() > 0
72
72
  for each dispatcherFacadeKey in dispatcherFacades
73
73
  dispatcherFacadeInstance = dispatcherFacades[dispatcherFacadeKey]
74
- dispatcherFacadeInstance.destroy()
74
+ dispatcherFacadeInstance.release()
75
75
  widget.viewModelState[scope.pluginKey][dispatcherFacadeKey] = invalid
76
76
  end for
77
77
  end if
@@ -378,7 +378,29 @@ namespace Rotor
378
378
  parentGroup.setLastFocusedHID(resolvedHID)
379
379
  end if
380
380
  end if
381
- end sub
381
+ end sub,
382
+
383
+ ' ---------------------------------------------------------------------
384
+ ' isFocused - Checks if this widget currently has focus
385
+ '
386
+ ' For a FocusItem: returns whether this item is the focused element
387
+ ' For a Group: returns whether any descendant within this group has focus (is in focus chain)
388
+ ' For widgets without focus config: returns false
389
+ '
390
+ ' @returns {boolean} True if focused (or in focus chain for groups), false otherwise
391
+ '
392
+ isFocused: function() as boolean
393
+ plugin = m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY]
394
+ focusItem = plugin.focusItemStack.get(m.HID)
395
+ if focusItem <> invalid
396
+ return focusItem.isFocused
397
+ end if
398
+ group = plugin.groupStack.get(m.HID)
399
+ if group <> invalid
400
+ return group.isFocused
401
+ end if
402
+ return false
403
+ end function
382
404
 
383
405
  }
384
406
 
@@ -754,6 +776,21 @@ namespace Rotor
754
776
  end if
755
777
  end sub
756
778
 
779
+ function delegateKeyPress(key as string) as boolean
780
+ focused = m.getFocusedItem()
781
+ if focused = invalid then return false
782
+
783
+ ' Bubble through ancestor groups (focused item already checked by caller)
784
+ focusChainGroups = m.findAncestorGroups(focused.HID)
785
+ for each groupHID in focusChainGroups
786
+ group = m.groupStack.get(groupHID)
787
+ handled = group.callKeyPressHandler(key)
788
+ if handled then return true
789
+ end for
790
+
791
+ return false
792
+ end function
793
+
757
794
  sub delegateLongPressChanged(isLongPress as boolean, key as string)
758
795
  focused = m.getFocusedItem()
759
796
  handled = focused.callLongPressHandler(isLongPress, key)
@@ -862,7 +899,7 @@ namespace Rotor
862
899
  currentAncestors = m.findAncestorGroups(m.globalFocusHID)
863
900
  newAncestors = m.findAncestorGroups(newHID)
864
901
  if currentAncestors.Count() > 0 and newAncestors.Count() > 0
865
- if currentAncestors[0] = newAncestors[0] then newHID = ""
902
+ if currentAncestors[0] = newAncestors[0] and newHID <> m.globalFocusHID then newHID = ""
866
903
  end if
867
904
  end if
868
905
 
@@ -1003,6 +1040,14 @@ namespace Rotor
1003
1040
 
1004
1041
  if true = press
1005
1042
 
1043
+ ' keyPressHandler: local (no bubbling), fires for all keys before navigation
1044
+ focusedItem = m.focusItemStack.get(m.globalFocusHID)
1045
+ if focusedItem <> invalid and focusedItem.keyPressHandler <> invalid
1046
+ if true = Rotor.Utils.callbackScoped(focusedItem.keyPressHandler, focusedItem.widget, key)
1047
+ return m.parseOnKeyEventResult(key, true, false)
1048
+ end if
1049
+ end if
1050
+
1006
1051
  if -1 < Rotor.Utils.findInArray([
1007
1052
  Rotor.Const.Direction.UP,
1008
1053
  Rotor.Const.Direction.RIGHT,
@@ -1080,6 +1125,9 @@ namespace Rotor
1080
1125
  return m.parseOnKeyEventResult(key, true, true)
1081
1126
 
1082
1127
  end if
1128
+
1129
+ ' Unhandled key (not direction, not OK) β†’ bubble through ancestor groups
1130
+ if m.delegateKeyPress(key) then return m.parseOnKeyEventResult(key, true, false)
1083
1131
  end if
1084
1132
 
1085
1133
  return m.parseOnKeyEventResult(key, false, false)
@@ -1256,7 +1304,6 @@ namespace Rotor
1256
1304
 
1257
1305
  class BaseFocusConfig
1258
1306
 
1259
- autoSetIsFocusedState as boolean
1260
1307
  staticDirection as object
1261
1308
 
1262
1309
  sub new (config as object)
@@ -1268,8 +1315,6 @@ namespace Rotor
1268
1315
  m.node = m.widget.node
1269
1316
  m.isFocused = config.isFocused ?? false
1270
1317
 
1271
- m.autoSetIsFocusedState = config.autoSetIsFocusedState ?? true
1272
-
1273
1318
  m.isEnabled = config.isEnabled ?? true
1274
1319
  m.enableSpatialNavigation = config.enableSpatialNavigation ?? false
1275
1320
  m.staticDirection = {}
@@ -1281,14 +1326,14 @@ namespace Rotor
1281
1326
 
1282
1327
  m.onFocusChanged = config.onFocusChanged
1283
1328
  m.longPressHandler = config.longPressHandler
1329
+ m.keyPressHandler = config.keyPressHandler
1284
1330
  m.onFocus = config.onFocus
1285
1331
  m.onBlur = config.onBlur
1286
1332
 
1287
1333
  Rotor.Utils.setCustomFields(m.node, { "isFocused": false }, true, true)
1288
1334
 
1289
- ' convenience (usually this is used on viewModelState)
1290
- if false = m.widget.viewModelState.DoesExist("isFocused") and true = m.autoSetIsFocusedState
1291
- m.widget.viewModelState.isFocused = false ' as default
1335
+ if m.widget.isViewModel = true and not m.widget.viewModelState.DoesExist("isFocused")
1336
+ m.widget.viewModelState.isFocused = false
1292
1337
  end if
1293
1338
 
1294
1339
  end sub
@@ -1304,6 +1349,7 @@ namespace Rotor
1304
1349
  onFocus as dynamic
1305
1350
  onBlur as dynamic
1306
1351
  longPressHandler as dynamic
1352
+ keyPressHandler as dynamic
1307
1353
  node as object
1308
1354
  widget as object
1309
1355
 
@@ -1374,6 +1420,14 @@ namespace Rotor
1374
1420
  end if
1375
1421
  end function
1376
1422
 
1423
+ function callKeyPressHandler(key as string) as boolean
1424
+ if Rotor.Utils.isFunction(m.keyPressHandler)
1425
+ return Rotor.Utils.callbackScoped(m.keyPressHandler, m.widget, key)
1426
+ else
1427
+ return false
1428
+ end if
1429
+ end function
1430
+
1377
1431
  sub destroy()
1378
1432
  m.widget = invalid
1379
1433
  m.node = invalid
@@ -1381,6 +1435,7 @@ namespace Rotor
1381
1435
  m.onFocus = invalid
1382
1436
  m.onBlur = invalid
1383
1437
  m.longPressHandler = invalid
1438
+ m.keyPressHandler = invalid
1384
1439
  end sub
1385
1440
 
1386
1441
  end class
@@ -1534,8 +1589,9 @@ namespace Rotor
1534
1589
  defaultFocusId = m.defaultFocusId
1535
1590
  end if
1536
1591
 
1592
+ focusItemsHIDlist = m.getGroupMembersHIDs()
1593
+
1537
1594
  if defaultFocusId <> ""
1538
- focusItemsHIDlist = m.getGroupMembersHIDs()
1539
1595
  if focusItemsHIDlist.Count() > 0
1540
1596
  ' Try find valid HID in focusItems by node id
1541
1597
  focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
@@ -1548,6 +1604,11 @@ namespace Rotor
1548
1604
  return defaultFocusId
1549
1605
  end if
1550
1606
 
1607
+ ' Last resort: pick the first available member of the group
1608
+ if focusItemsHIDlist.Count() > 0
1609
+ return focusItemsHIDlist[0]
1610
+ end if
1611
+
1551
1612
  return HID
1552
1613
  end function
1553
1614
 
@@ -1565,11 +1626,8 @@ namespace Rotor
1565
1626
  if m.isFocused = isFocused then return
1566
1627
 
1567
1628
  m.isFocused = isFocused
1568
-
1569
- if m.autoSetIsFocusedState
1570
- m.widget.viewModelState.isFocused = isFocused
1571
- end if
1572
1629
  m.node.setField("isFocused", isFocused)
1630
+ if m.widget.isViewModel = true then m.widget.viewModelState.isFocused = isFocused
1573
1631
  m.callOnFocusedFnOnWidget(isFocused)
1574
1632
  end sub
1575
1633
 
@@ -1588,7 +1646,7 @@ namespace Rotor
1588
1646
  sub new (config as object)
1589
1647
  super(config)
1590
1648
 
1591
- m.onSelect = config.onSelect ?? ""
1649
+ m.onSelect = config.onSelect ?? config.ok ?? ""
1592
1650
  m.enableNativeFocus = config.enableNativeFocus ?? false
1593
1651
  end sub
1594
1652
 
@@ -1686,12 +1744,8 @@ namespace Rotor
1686
1744
  if m.isFocused = isFocused then return
1687
1745
 
1688
1746
  m.isFocused = isFocused
1689
-
1690
- if m.autoSetIsFocusedState
1691
- m.widget.viewModelState.isFocused = isFocused
1692
- end if
1693
-
1694
1747
  m.node.setField("isFocused", isFocused)
1748
+ if m.widget.isViewModel = true then m.widget.viewModelState.isFocused = isFocused
1695
1749
 
1696
1750
  if enableNativeFocus or m.enableNativeFocus
1697
1751
  m.node.setFocus(isFocused)