rotor-framework 0.8.0 β 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 +1 -1
- package/package.json +1 -1
- package/src/source/RotorFramework.bs +12 -8
- package/src/source/RotorFrameworkTask.bs +90 -52
- package/src/source/base/BaseReducer.bs +16 -40
- package/src/source/base/BaseViewModel.bs +0 -2
- package/src/source/base/BaseWidget.bs +2 -1
- package/src/source/base/DispatcherCore.bs +3 -3
- package/src/source/engine/builder/WidgetCreate.bs +4 -4
- package/src/source/engine/providers/Dispatcher.bs +5 -2
- package/src/source/plugins/DispatcherProviderPlugin.bs +1 -1
- package/src/source/plugins/FieldsPlugin.bs +3 -0
- package/src/source/plugins/FocusPlugin.bs +74 -20
- package/src/source/plugins/ObserverPlugin.bs +19 -9
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
|
-

|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
'
|
|
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
|
|
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
|
|
340
|
-
throw "registerSourceObject() is only available on the task thread.
|
|
341
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
|
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 -
|
|
140
|
+
' registerSourceObject - Creates and registers a source object for event routing
|
|
138
141
|
'
|
|
139
|
-
'
|
|
140
|
-
' - Identity-based: If sourceObject implements
|
|
141
|
-
'
|
|
142
|
-
' - Broadcast: If sourceObject does NOT implement
|
|
143
|
-
'
|
|
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}
|
|
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
|
|
151
|
-
sourceObject
|
|
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
|
|
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
|
|
163
|
-
m.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
188
|
+
return sourceObject
|
|
189
|
+
end function
|
|
170
190
|
|
|
171
191
|
' ---------------------------------------------------------------------
|
|
172
|
-
' unregisterSourceObject - Unregisters a
|
|
192
|
+
' unregisterSourceObject - Unregisters a source object
|
|
173
193
|
'
|
|
174
|
-
'
|
|
194
|
+
' Identity-based objects: removes by identity from registry.
|
|
195
|
+
' Broadcast objects: removes dispatcher subscriber, cleans up if no subscribers remain.
|
|
175
196
|
'
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
|
339
|
+
' Broadcast to shared source object subscribers
|
|
304
340
|
if not routed
|
|
305
|
-
for each
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
53
|
-
m.
|
|
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.
|
|
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.
|
|
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 -
|
|
194
|
+
' registerSourceObject - Creates and registers a source object with the framework
|
|
195
195
|
'
|
|
196
|
-
'
|
|
197
|
-
'
|
|
198
|
-
'
|
|
199
|
-
'
|
|
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 {
|
|
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
|
-
|
|
205
|
+
function registerSourceObject(typeName as string, eventFilter = invalid as dynamic) as object
|
|
206
206
|
frameworkInstance = GetGlobalAA().rotor_framework_helper.frameworkInstance
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -69,11 +69,12 @@ namespace Rotor
|
|
|
69
69
|
' @param {object} state - New state to notify listeners about
|
|
70
70
|
'
|
|
71
71
|
sub notifyListeners(state as object)
|
|
72
|
-
listenerCount = m.listeners.Count()
|
|
73
72
|
listenerIndex = 0
|
|
74
73
|
|
|
75
74
|
' Iterate through all listeners
|
|
76
|
-
|
|
75
|
+
' Use m.listeners.Count() directly (not cached) to handle external listener
|
|
76
|
+
' removal during iteration (e.g., ViewModel destroyed in a callback)
|
|
77
|
+
while listenerIndex < m.listeners.Count()
|
|
77
78
|
listener = m.listeners[listenerIndex]
|
|
78
79
|
scope = listener.listenerScope
|
|
79
80
|
|
|
@@ -116,7 +117,6 @@ namespace Rotor
|
|
|
116
117
|
listener.listenerScope = invalid
|
|
117
118
|
listener.callback = invalid
|
|
118
119
|
m.listeners.Delete(listenerIndex)
|
|
119
|
-
listenerCount--
|
|
120
120
|
else
|
|
121
121
|
listenerIndex++
|
|
122
122
|
end if
|
|
@@ -136,20 +136,20 @@ namespace Rotor.ViewBuilder
|
|
|
136
136
|
m.getFrameworkInstance().builder.erase(payloads, shouldSkipNodePool, parentHID)
|
|
137
137
|
end sub
|
|
138
138
|
|
|
139
|
-
'
|
|
140
|
-
widget.
|
|
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.
|
|
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.
|
|
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
|
-
'
|
|
63
|
+
' release - Releases this dispatcher facade by removing all listeners and clearing references
|
|
64
64
|
'
|
|
65
|
-
|
|
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.
|
|
74
|
+
dispatcherFacadeInstance.release()
|
|
75
75
|
widget.viewModelState[scope.pluginKey][dispatcherFacadeKey] = invalid
|
|
76
76
|
end for
|
|
77
77
|
end if
|
|
@@ -149,6 +149,9 @@ namespace Rotor
|
|
|
149
149
|
' Resolve value from key path
|
|
150
150
|
asset = Rotor.Utils.getValueByKeyPath(source, matchKey)
|
|
151
151
|
|
|
152
|
+
' Skip unresolved keys (e.g. @ in email addresses)
|
|
153
|
+
if asset = invalid then goto nextResult
|
|
154
|
+
|
|
152
155
|
' Handle string vs non-string results
|
|
153
156
|
if Rotor.Utils.isString(asset)
|
|
154
157
|
' String interpolation - replace in original string
|
|
@@ -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)
|
|
@@ -1235,7 +1283,7 @@ namespace Rotor
|
|
|
1235
1283
|
foundHID = ""
|
|
1236
1284
|
for each HID in possibleFocusItems
|
|
1237
1285
|
focusItem = m.get(HID)
|
|
1238
|
-
if focusItem
|
|
1286
|
+
if focusItem?.id = nodeId
|
|
1239
1287
|
foundHID = focusItem.HID
|
|
1240
1288
|
exit for
|
|
1241
1289
|
end if
|
|
@@ -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
|
-
|
|
1290
|
-
|
|
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.isInFocusChain = 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)
|
|
@@ -385,7 +385,7 @@ namespace Rotor
|
|
|
385
385
|
' Represents a single observer configuration for a node field.
|
|
386
386
|
'
|
|
387
387
|
' Responsibilities:
|
|
388
|
-
' - Stores observer configuration (callback, conditions, etc.)
|
|
388
|
+
' - Stores observer configuration (callback or handler, conditions, etc.)
|
|
389
389
|
' - Sets up initial field value if provided
|
|
390
390
|
' - Provides info fields for observeFieldScoped
|
|
391
391
|
' - Executes callbacks in correct scope
|
|
@@ -407,7 +407,8 @@ namespace Rotor
|
|
|
407
407
|
value as dynamic ' Initial field value (if any)
|
|
408
408
|
once as boolean ' Remove observer after first trigger
|
|
409
409
|
until as function ' Conditional removal function
|
|
410
|
-
callback as
|
|
410
|
+
callback as dynamic ' Callback function called WITH payload (mutually exclusive with handler)
|
|
411
|
+
handler as dynamic ' Handler function called WITHOUT arguments (mutually exclusive with callback)
|
|
411
412
|
parsePayload as function ' Payload transformation function
|
|
412
413
|
alwaysNotify as boolean ' Field alwaysNotify flag
|
|
413
414
|
|
|
@@ -442,9 +443,13 @@ namespace Rotor
|
|
|
442
443
|
m.once = config?.once ?? false
|
|
443
444
|
m.until = config?.until
|
|
444
445
|
|
|
445
|
-
' Set callback (required)
|
|
446
|
-
m.callback = config?.callback
|
|
447
|
-
|
|
446
|
+
' Set callback or handler (one required, mutually exclusive)
|
|
447
|
+
m.callback = config?.callback
|
|
448
|
+
m.handler = config?.handler
|
|
449
|
+
|
|
450
|
+
if m.callback = invalid and m.handler = invalid
|
|
451
|
+
throw "Observer requires either 'callback' or 'handler' configuration"
|
|
452
|
+
end if
|
|
448
453
|
|
|
449
454
|
' Set payload parser (optional)
|
|
450
455
|
m.parsePayload = config?.parsePayload ?? function(payload)
|
|
@@ -498,14 +503,19 @@ namespace Rotor
|
|
|
498
503
|
' =============================================================
|
|
499
504
|
|
|
500
505
|
' ---------------------------------------------------------------------
|
|
501
|
-
' notify - Executes observer callback
|
|
506
|
+
' notify - Executes observer callback or handler
|
|
502
507
|
'
|
|
503
|
-
' Calls the configured callback
|
|
508
|
+
' Calls the configured callback (with payload) or handler (without arguments)
|
|
509
|
+
' in the correct widget scope.
|
|
504
510
|
'
|
|
505
|
-
' @param {dynamic} payload - Data to pass to callback
|
|
511
|
+
' @param {dynamic} payload - Data to pass to callback (ignored for handler)
|
|
506
512
|
'
|
|
507
513
|
sub notify(payload as dynamic)
|
|
508
|
-
|
|
514
|
+
if m.handler <> invalid
|
|
515
|
+
Rotor.Utils.callbackScoped(m.handler, m.listenerScope)
|
|
516
|
+
else
|
|
517
|
+
Rotor.Utils.callbackScoped(m.callback, m.listenerScope, payload)
|
|
518
|
+
end if
|
|
509
519
|
end sub
|
|
510
520
|
|
|
511
521
|
' =============================================================
|