rotor-framework 0.7.7 β†’ 0.8.1

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.7.7-blue?label=Documents%20TAG)
99
+ ![Version](https://img.shields.io/badge/version-v0.8.1-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.7.7",
3
+ "version": "0.8.1",
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",
@@ -52,8 +52,9 @@
52
52
  },
53
53
  "homepage": "https://github.com/mobalazs/rotor-framework#readme",
54
54
  "devDependencies": {
55
- "@rokucommunity/bslint": "^1.0.0-alpha.48",
56
- "brighterscript": "^1.0.0-alpha.48",
57
- "rooibos-roku": "file:./vendor/rooibos-roku-6.0.0-alpha.48-fixed354.tgz"
55
+ "@rokucommunity/bslint": "1.0.0-alpha.50",
56
+ "brighterscript": "1.0.0-alpha.50",
57
+ "roku-deploy": "^3.16.1",
58
+ "rooibos-roku": "6.0.0-alpha.50"
58
59
  }
59
60
  }
@@ -4,7 +4,7 @@
4
4
  ' β–β–›β–€β–šβ––β–β–Œ β–β–Œ β–ˆ β–β–Œ β–β–Œβ–β–›β–€β–šβ–– β–β–›β–€β–€β–˜β–β–›β–€β–šβ––β–β–›β–€β–œβ–Œβ–β–Œ β–β–Œβ–β–›β–€β–€β–˜β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–›β–€β–šβ––β–β–›β–šβ––
5
5
  ' β–β–Œ β–β–Œβ–β–šβ–„β–žβ–˜ β–ˆ β–β–šβ–„β–žβ–˜β–β–Œ β–β–Œ β–β–Œ β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–™β–„β–„β––β–β–™β–ˆβ–Ÿβ–Œβ–β–šβ–„β–žβ–˜β–β–Œ β–β–Œβ–β–Œ β–β–Œ
6
6
  ' Rotor Frameworkβ„’
7
- ' Version 0.7.7
7
+ ' Version 0.8.1
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.7.7"
89
+ version = "0.8.1"
90
90
 
91
91
  config = {
92
92
  tasks: invalid, ' @array (optional)
@@ -333,6 +333,20 @@ namespace Rotor
333
333
  return m.builder.nodePool.presetNodePool(config)
334
334
  end function
335
335
 
336
+ ' ---------------------------------------------------------------------
337
+ ' registerSourceObject - Not available on render thread
338
+ '
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
+
343
+ ' ---------------------------------------------------------------------
344
+ ' unregisterSourceObject - Not available on render thread
345
+ '
346
+ public sub unregisterSourceObject(objectId as string)
347
+ throw "unregisterSourceObject() is only available on the task thread."
348
+ end sub
349
+
336
350
  ' =====================================================================
337
351
  ' INTERNAL METHODS - INITIALIZATION AND TASK SYNCING
338
352
  ' =====================================================================
@@ -4,7 +4,7 @@
4
4
  ' β–β–›β–€β–šβ––β–β–Œ β–β–Œ β–ˆ β–β–Œ β–β–Œβ–β–›β–€β–šβ–– β–β–›β–€β–€β–˜β–β–›β–€β–šβ––β–β–›β–€β–œβ–Œβ–β–Œ β–β–Œβ–β–›β–€β–€β–˜β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–›β–€β–šβ––β–β–›β–šβ––
5
5
  ' β–β–Œ β–β–Œβ–β–šβ–„β–žβ–˜ β–ˆ β–β–šβ–„β–žβ–˜β–β–Œ β–β–Œ β–β–Œ β–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–Œ β–β–Œβ–β–™β–„β–„β––β–β–™β–ˆβ–Ÿβ–Œβ–β–šβ–„β–žβ–˜β–β–Œ β–β–Œβ–β–Œ β–β–Œ
6
6
  ' Rotor Frameworkβ„’
7
- ' Version 0.7.7
7
+ ' Version 0.8.1
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.7.7"
73
+ version = "0.8.1"
74
74
 
75
75
  config = {
76
76
  tasks: invalid, ' optional
@@ -86,8 +86,10 @@ namespace Rotor
86
86
  taskNode as object
87
87
  dispatcherProvider as object
88
88
  port as object
89
- asyncTransferRegistry = {} ' transferId -> { dispatcherId, context }
90
- deviceInfoRegistry = {} ' deviceInfoId -> { dispatcherId, context, deviceInfo }
89
+ sourceObjectRegistry = {} ' identity -> { dispatcherId, objectId, eventFilter }
90
+ sourceObjectIdIndex = {} ' objectId -> identity (reverse index for unregistration)
91
+ sourceObjectTypeRegistry = [] ' [{ dispatcherId, objectId, eventFilter }] - broadcast routing
92
+ private _eventFilterFn = invalid as dynamic
91
93
  onTick as function
92
94
 
93
95
  ' ---------------------------------------------------------------------
@@ -132,42 +134,59 @@ namespace Rotor
132
134
  end function
133
135
 
134
136
  ' ---------------------------------------------------------------------
135
- ' registerAsyncTransfer - Registers an async transfer with dispatcher context
137
+ ' registerSourceObject - Registers a source object for event routing
136
138
  '
137
- ' This method associates a roUrlTransfer identity with a dispatcher ID and
138
- ' optional context data. When the transfer completes and sends a roUrlEvent,
139
- ' the framework will route it to the correct dispatcher's asyncReducerCallback.
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).
140
144
  '
141
- ' @param {string} transferId - The transfer.GetIdentity().ToStr() value
142
- ' @param {string} dispatcherId - Dispatcher ID that initiated the transfer
143
- ' @param {object} context - Optional user data to pass back in callback
145
+ ' @param {string} objectId - Unique identifier for this registration
146
+ ' @param {string} dispatcherId - Dispatcher ID that will handle events
147
+ ' @param {object} sourceObject - The source object (SetMessagePort will be called)
148
+ ' @param {function} eventFilter - Optional filter function. Receives msg, returns boolean.
144
149
  '
145
- public sub registerAsyncTransfer(transferId as string, dispatcherId as string, context = invalid as dynamic)
146
- m.asyncTransferRegistry[transferId] = {
147
- dispatcherId: dispatcherId,
148
- context: context
149
- }
150
+ public sub registerSourceObject(objectId as string, dispatcherId as string, sourceObject as object, eventFilter = invalid as dynamic)
151
+ sourceObject.SetMessagePort(m.port)
152
+ if FindMemberFunction(sourceObject, "GetIdentity") <> invalid
153
+ ' Identity-based routing
154
+ identity = sourceObject.GetIdentity().ToStr()
155
+ m.sourceObjectRegistry[identity] = {
156
+ dispatcherId: dispatcherId,
157
+ objectId: objectId,
158
+ eventFilter: eventFilter
159
+ }
160
+ m.sourceObjectIdIndex[objectId] = identity
161
+ else
162
+ ' Broadcast routing
163
+ m.sourceObjectTypeRegistry.push({
164
+ dispatcherId: dispatcherId,
165
+ objectId: objectId,
166
+ eventFilter: eventFilter
167
+ })
168
+ end if
150
169
  end sub
151
170
 
152
171
  ' ---------------------------------------------------------------------
153
- ' registerDeviceInfo - Registers a roDeviceInfo for event listening
154
- '
155
- ' This method associates a roDeviceInfo object with a dispatcher ID.
156
- ' When device info events occur (linkStatus, etc.), the framework will
157
- ' route them to the correct dispatcher's asyncReducerCallback.
172
+ ' unregisterSourceObject - Unregisters a port-based object by its objectId
158
173
  '
159
- ' @param {string} deviceInfoId - Unique identifier for this registration
160
- ' @param {string} dispatcherId - Dispatcher ID that will handle events
161
- ' @param {object} deviceInfo - The roDeviceInfo object (must have SetMessagePort called)
162
- ' @param {object} context - Optional user data to pass back in callback
174
+ ' @param {string} objectId - The unique identifier used during registration
163
175
  '
164
- public sub registerDeviceInfo(deviceInfoId as string, dispatcherId as string, deviceInfo as object, context = invalid as dynamic)
165
- deviceInfo.SetMessagePort(m.port)
166
- m.deviceInfoRegistry[deviceInfoId] = {
167
- dispatcherId: dispatcherId,
168
- deviceInfo: deviceInfo,
169
- context: context
170
- }
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)
188
+ end if
189
+ end for
171
190
  end sub
172
191
 
173
192
  ' ---------------------------------------------------------------------
@@ -241,53 +260,63 @@ namespace Rotor
241
260
 
242
261
  end if
243
262
  else
244
-
263
+ ' Cross-thread state change: notify task-side listeners
245
264
  data = msg.getData()
246
- extraInfo = msg.GetInfo() ' Info AA passed during observeFieldScoped
247
-
248
- if extraInfo?.dispatcherId <> invalid and m.dispatcherProvider.get(extraInfo?.dispatcherId) <> invalid
249
- ' Catch by dispatcherId
250
- m.dispatcherProvider.get(extraInfo?.dispatcherId).asyncReducerCallback(msg, extraInfo?.context as dynamic)
251
- else
252
- dispatcherId = fieldId
253
- dispatcherInstance = m.dispatcherProvider.get(dispatcherId)
265
+ dispatcherId = fieldId
266
+ dispatcherInstance = m.dispatcherProvider.get(dispatcherId)
267
+ if dispatcherInstance <> invalid
254
268
  dispatcherInstance.notifyListeners(data)
255
269
  end if
256
270
 
257
271
  end if
258
- else if msgType = "roUrlEvent"
259
- ' Handle async transfer responses
260
- transferId = msg.GetSourceIdentity().ToStr()
261
-
262
- if m.asyncTransferRegistry.DoesExist(transferId)
263
- transferData = m.asyncTransferRegistry[transferId]
264
- dispatcherId = transferData.dispatcherId
265
-
266
- ' Cleanup registry entry (Note: order is important - this make it reusable immediately)
267
- m.asyncTransferRegistry.delete(transferId)
268
-
269
- dispatcherInstance = m.dispatcherProvider.get(dispatcherId)
270
- if dispatcherInstance <> invalid
271
- ' Route to dispatcher with wrapped message
272
- dispatcherInstance.asyncReducerCallback(msg as roUrlEvent, transferData?.context as dynamic)
273
- end if
272
+ else
273
+ ' Generic source object routing
274
+ routed = false
275
+
276
+ ' Try identity-based routing
277
+ if m.sourceObjectRegistry.count() > 0
278
+ try
279
+ sourceIdentity = msg.GetSourceIdentity().ToStr()
280
+ if m.sourceObjectRegistry.DoesExist(sourceIdentity)
281
+ entry = m.sourceObjectRegistry[sourceIdentity]
282
+
283
+ ' Apply event filter if provided
284
+ allowed = true
285
+ if entry.eventFilter <> invalid
286
+ m._eventFilterFn = entry.eventFilter
287
+ allowed = m._eventFilterFn(msg)
288
+ end if
274
289
 
290
+ if allowed
291
+ dispatcherInstance = m.dispatcherProvider.get(entry.dispatcherId)
292
+ if dispatcherInstance <> invalid
293
+ dispatcherInstance.onSourceEvent(msg)
294
+ end if
295
+ end if
296
+ routed = true
297
+ end if
298
+ catch e
299
+ ' Event doesn't support GetSourceIdentity - fall through to broadcast
300
+ end try
275
301
  end if
276
- else if msgType = "roDeviceInfoEvent"
277
- ' Handle device info events (ignore appFocused, etc.)
278
- if m.deviceInfoRegistry.count() > 0
279
- eventInfo = msg.GetInfo()
280
- ' Only process linkStatus events, ignore appFocused and other events
281
- if eventInfo?.linkStatus <> invalid
282
- ' Route to all registered device info dispatchers
283
- for each deviceInfoId in m.deviceInfoRegistry
284
- registryEntry = m.deviceInfoRegistry[deviceInfoId]
285
- dispatcherInstance = m.dispatcherProvider.get(registryEntry.dispatcherId)
302
+
303
+ ' Broadcast to all non-identity registered dispatchers
304
+ 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
312
+
313
+ if allowed
314
+ dispatcherInstance = m.dispatcherProvider.get(entry.dispatcherId)
286
315
  if dispatcherInstance <> invalid
287
- dispatcherInstance.asyncReducerCallback(eventInfo, registryEntry?.context as dynamic)
316
+ dispatcherInstance.onSourceEvent(msg)
288
317
  end if
289
- end for
290
- end if
318
+ end if
319
+ end for
291
320
  end if
292
321
  end if
293
322
  end if
@@ -354,8 +383,9 @@ namespace Rotor
354
383
  ' Destroys dispatcher provider and clears global framework helper.
355
384
  '
356
385
  public sub destroy()
357
- m.asyncTransferRegistry.clear()
358
- m.deviceInfoRegistry.clear()
386
+ m.sourceObjectRegistry.clear()
387
+ m.sourceObjectIdIndex.clear()
388
+ m.sourceObjectTypeRegistry.clear()
359
389
  m.dispatcherProvider.destroy()
360
390
 
361
391
  globalScope = GetGlobalAA()
@@ -32,12 +32,12 @@ namespace Rotor
32
32
  dispatch as function ' Dispatch intent to owner dispatcher
33
33
  dispatchTo as function ' Dispatch intent to dispatcher by ID
34
34
  getStateFrom as function ' Get state from dispatcher by ID
35
- ownerDispatcher as object ' Owning dispatcher instance
36
- ownerDispatcherId as string ' Owning dispatcher ID
35
+ dispatcher as object ' Owning dispatcher instance
36
+ dispatcherId as string ' Owning dispatcher ID
37
37
 
38
38
  ' Internal
39
39
  middlewareFnScoped as dynamic ' Currently executing middleware
40
-
40
+
41
41
  ' =============================================================
42
42
  ' CONSTRUCTOR
43
43
  ' =============================================================
@@ -56,12 +56,12 @@ namespace Rotor
56
56
 
57
57
  ' Inject dispatch method for dispatching intents
58
58
  m.dispatch = sub(intent as object)
59
- m.ownerDispatcher.dispatch(intent)
59
+ m.dispatcher.dispatch(intent)
60
60
  end sub
61
61
 
62
62
  ' Inject getState method for accessing current state
63
63
  m.getState = function() as Rotor.Model
64
- return m.ownerDispatcher.getState()
64
+ return m.dispatcher.getState()
65
65
  end function
66
66
 
67
67
  ' Inject dispatchTo method for dispatching intents to other dispatchers
@@ -105,6 +105,19 @@ namespace Rotor
105
105
  return state
106
106
  end function
107
107
 
108
+ ' ---------------------------------------------------------------------
109
+ ' onCreateDispatcher - Lifecycle hook called after dispatcher is fully registered
110
+ '
111
+ ' Called by the framework after the dispatcher is created and registered
112
+ ' with the DispatcherProvider. At this point, dispatcherId and all
113
+ ' framework integrations are available.
114
+ '
115
+ ' Override this method to perform initialization that requires
116
+ ' the dispatcher to be fully set up (e.g., source object registration).
117
+ '
118
+ sub onCreateDispatcher()
119
+ end sub
120
+
108
121
  ' =============================================================
109
122
  ' MIDDLEWARE
110
123
  ' =============================================================
@@ -174,34 +187,37 @@ namespace Rotor
174
187
  end function
175
188
 
176
189
  ' =============================================================
177
- ' ASYNC TRANSFER SUPPORT
190
+ ' SOURCE OBJECT SUPPORT
178
191
  ' =============================================================
179
192
 
180
193
  ' ---------------------------------------------------------------------
181
- ' registerAsyncTransfer - Registers a roUrlTransfer with the framework
182
- '
183
- ' Helper method to simplify async HTTP request tracking. Call this after
184
- ' creating a roUrlTransfer but BEFORE calling AsyncGetToString() or other
185
- ' async methods.
194
+ ' registerSourceObject - Registers a source object with the framework
186
195
  '
187
- ' @param {object} transfer - The roUrlTransfer object
188
- ' @param {object} context - Optional data to receive in asyncReducerCallback
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.
189
201
  '
190
- ' Example:
191
- ' transfer = CreateObject("roUrlTransfer")
192
- ' transfer.SetUrl("https://api.example.com/data")
193
- ' transfer.SetMessagePort(m.port)
194
- ' m.registerAsyncTransfer(transfer, { userId: 123 })
195
- ' transfer.AsyncGetToString()
202
+ ' @param {object} sourceObject - The source object
203
+ ' @param {function} eventFilter - Optional filter function. Receives msg, returns boolean.
196
204
  '
197
- sub registerAsyncTransfer(transfer as object, context = invalid as dynamic)
205
+ sub registerSourceObject(sourceObject as object, eventFilter = invalid as dynamic)
198
206
  frameworkInstance = GetGlobalAA().rotor_framework_helper.frameworkInstance
199
- transferId = transfer.GetIdentity().ToStr()
200
207
 
201
- ' Only call if the method exists (task thread only)
202
- if Rotor.Utils.isFunction(frameworkInstance?.registerAsyncTransfer)
203
- frameworkInstance.registerAsyncTransfer(transferId, m.ownerDispatcherId, context)
204
- end if
208
+ objectId = m.getSourceObjectId(sourceObject)
209
+ frameworkInstance.registerSourceObject(objectId, m.dispatcherId, sourceObject, eventFilter)
210
+ end sub
211
+
212
+ ' ---------------------------------------------------------------------
213
+ ' unregisterSourceObject - Unregisters a source object from the framework
214
+ '
215
+ ' @param {object} sourceObject - The source object to unregister
216
+ '
217
+ sub unregisterSourceObject(sourceObject as object)
218
+ frameworkInstance = GetGlobalAA().rotor_framework_helper.frameworkInstance
219
+ objectId = m.getSourceObjectId(sourceObject)
220
+ frameworkInstance.unregisterSourceObject(objectId)
205
221
  end sub
206
222
 
207
223
  ' =============================================================
@@ -209,11 +225,28 @@ namespace Rotor
209
225
  ' =============================================================
210
226
 
211
227
  ' ---------------------------------------------------------------------
212
- ' asyncReducerCallback - Callback for async middleware operations
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
+ ' ---------------------------------------------------------------------
245
+ ' onSourceEvent - Callback for source object events
213
246
  '
214
- ' @param {object} msg - Message from async operation
247
+ ' @param {object} msg - Raw message from source object (roUrlEvent, roDeviceInfoEvent, etc.)
215
248
  '
216
- sub asyncReducerCallback(msg as roUrlEvent, context as dynamic)
249
+ sub onSourceEvent(msg as object)
217
250
  ' Override in subclass to handle async responses
218
251
  end sub
219
252
 
@@ -225,7 +258,7 @@ namespace Rotor
225
258
  ' destroy - Cleans up reducer references
226
259
  '
227
260
  sub destroy()
228
- m.ownerDispatcher = invalid
261
+ m.dispatcher = invalid
229
262
  m.port = invalid
230
263
  end sub
231
264
 
@@ -82,8 +82,6 @@ namespace Rotor
82
82
  ' Called automatically when setProps() is invoked.
83
83
  '
84
84
  sub onUpdateView()
85
- ' Re-render template
86
- m.render()
87
85
  end sub
88
86
 
89
87
  ' ---------------------------------------------------------------------
@@ -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
- while listenerIndex < listenerCount
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
@@ -62,8 +62,8 @@ namespace Rotor
62
62
 
63
63
  ' Link reducer to this dispatcher
64
64
  m.reducerInstance = reducerInstance
65
- m.reducerInstance.ownerDispatcher = m
66
- m.reducerInstance.ownerDispatcherId = dispatcherId
65
+ m.reducerInstance.dispatcher = m
66
+ m.reducerInstance.dispatcherId = dispatcherId
67
67
  m.modelInstance = modelInstance
68
68
 
69
69
  ' Create field on task node for render thread communication
@@ -75,6 +75,10 @@ namespace Rotor
75
75
  ' Register with dispatcher provider
76
76
  dispatcherProvider = globalScope.rotor_framework_helper.frameworkInstance.dispatcherProvider
77
77
  dispatcherProvider.registerDispatcher(m, dispatcherId)
78
+
79
+ ' Lifecycle hook - called after dispatcher is fully registered
80
+ m.reducerInstance.onCreateDispatcher()
81
+
78
82
  end sub
79
83
 
80
84
  ' =============================================================
@@ -155,12 +159,12 @@ namespace Rotor
155
159
  ' =============================================================
156
160
 
157
161
  ' ---------------------------------------------------------------------
158
- ' asyncReducerCallback - Routes async callback to reducer
162
+ ' onSourceEvent - Routes async callback to reducer
159
163
  '
160
164
  ' @param {object} msg - Message from async operation
161
165
  '
162
- sub asyncReducerCallback(msg as roUrlEvent, context as dynamic)
163
- m.reducerInstance.asyncReducerCallback(msg as roUrlEvent, context as dynamic)
166
+ sub onSourceEvent(msg as object)
167
+ m.reducerInstance.onSourceEvent(msg)
164
168
  end sub
165
169
 
166
170
  ' =============================================================
@@ -97,7 +97,7 @@ namespace Rotor.ViewBuilder
97
97
  end function
98
98
 
99
99
  ' render - Renders widget updates (self, descendants, or children) *'
100
- widget.render = sub(payloads as dynamic, params = {} as object)
100
+ widget.render = sub(payloads = invalid as dynamic, params = {} as object)
101
101
  if payloads = invalid and m.isViewModel = true
102
102
  ' Self update (only for viewModels)
103
103
  payloads = m.template()
@@ -264,15 +264,6 @@ namespace Rotor.ViewBuilder
264
264
  ' Apply threshold to ALL speech (both flush=true and flush=false)
265
265
  ' This prevents rapid interruptions from focus changes (e.g., "Home Home Home")
266
266
  ' If new request comes within 300ms, replace pending (filter rapid changes)
267
- #if debug
268
- if m.pendingSpeech <> invalid
269
- ' ? "[TTS_SERVICE][THRESHOLD] Replacing pending: '"; m.pendingSpeech.textToSpeak; "' with: '"; textToSpeak; "'"
270
- else
271
- flushLabel = ""
272
- if shouldFlush then flushLabel = " (will flush)"
273
- ' ? "[TTS_SERVICE][THRESHOLD] Pending: '"; textToSpeak; "' ("; m.debounceDelay; "ms)"; flushLabel
274
- end if
275
- #end if
276
267
  m.pendingSpeech = {
277
268
  textToSpeak: textToSpeak,
278
269
  shouldFlush: shouldFlush,
@@ -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
@@ -1154,8 +1154,6 @@ namespace Rotor
1154
1154
  if not isGroup and candidate.isEnabled = false then continue for
1155
1155
 
1156
1156
  candidateMetrics = candidate.refreshBounding()
1157
- candidateLeft = candidateMetrics.segments[Rotor.Const.Segment.LEFT]
1158
- candidateTop = candidateMetrics.segments[Rotor.Const.Segment.TOP]
1159
1157
  ' Pass appropriate reference segments based on direction
1160
1158
  if direction = "left" or direction = "right"
1161
1159
  result = validator(candidateMetrics.segments, refSegmentLeft, refSegmentRight)
@@ -1237,7 +1235,7 @@ namespace Rotor
1237
1235
  foundHID = ""
1238
1236
  for each HID in possibleFocusItems
1239
1237
  focusItem = m.get(HID)
1240
- if focusItem.id = nodeId
1238
+ if focusItem?.id = nodeId
1241
1239
  foundHID = focusItem.HID
1242
1240
  exit for
1243
1241
  end if
@@ -1539,33 +1537,28 @@ namespace Rotor
1539
1537
  if defaultFocusId <> ""
1540
1538
  focusItemsHIDlist = m.getGroupMembersHIDs()
1541
1539
  if focusItemsHIDlist.Count() > 0
1542
-
1543
1540
  ' Try find valid HID in focusItems by node id
1544
1541
  focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1545
1542
  if focusItemHID <> ""
1546
- HID = focusItemHID
1543
+ return focusItemHID
1547
1544
  end if
1548
-
1549
- else
1550
-
1551
- return defaultFocusId
1552
-
1553
1545
  end if
1546
+ ' If not found as focusItem, return defaultFocusId string
1547
+ ' so capturingFocus_recursively can try to resolve it as a group
1548
+ return defaultFocusId
1554
1549
  end if
1555
1550
 
1556
1551
  return HID
1557
1552
  end function
1558
1553
 
1559
1554
  function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1560
- HID = ""
1561
- for each HID in focusItemsHIDlist
1562
- focusItem = m.focusItemsRef.get(HID)
1555
+ for each itemHID in focusItemsHIDlist
1556
+ focusItem = m.focusItemsRef.get(itemHID)
1563
1557
  if focusItem <> invalid and focusItem.id = nodeId
1564
- HID = focusItem.HID
1565
- exit for
1558
+ return focusItem.HID
1566
1559
  end if
1567
1560
  end for
1568
- return HID
1561
+ return ""
1569
1562
  end function
1570
1563
 
1571
1564
  sub applyFocus(isFocused as boolean)
@@ -1574,7 +1567,7 @@ namespace Rotor
1574
1567
  m.isFocused = isFocused
1575
1568
 
1576
1569
  if m.autoSetIsFocusedState
1577
- m.widget.viewModelState.isInFocusChain = isFocused
1570
+ m.widget.viewModelState.isFocused = isFocused
1578
1571
  end if
1579
1572
  m.node.setField("isFocused", isFocused)
1580
1573
  m.callOnFocusedFnOnWidget(isFocused)
@@ -34,56 +34,36 @@ namespace Rotor
34
34
  sub new(pluginKey = "observer" as string)
35
35
  super()
36
36
  m.pluginKey = pluginKey
37
- end sub
38
-
39
- ' =============================================================
40
- ' LIFECYCLE HOOKS
41
- ' =============================================================
42
-
43
- hooks = {
44
- ' ---------------------------------------------------------------------
45
- ' beforeMount - Attaches observers when widget is mounted
46
- '
47
- ' Called during widget creation to register all observers defined in the widget config.
48
- '
49
- ' @param {object} scope - Plugin instance (m)
50
- ' @param {object} widget - Widget being mounted
51
- '
52
- beforeMount: sub(scope as object, widget as object)
53
- config = widget[scope.pluginKey]
54
- scope.attach(widget.node, config, widget)
55
- end sub,
56
37
 
57
- ' ---------------------------------------------------------------------
58
- ' beforeUpdate - Updates observers when widget config changes
59
- '
60
- ' Detaches old observers and attaches new ones based on updated configuration.
38
+ ' WORKAROUND: Hooks must be assigned here in the constructor body
39
+ ' (after super()), NOT as a class-level field initializer.
61
40
  '
62
- ' @param {object} scope - Plugin instance (m)
63
- ' @param {object} widget - Widget being updated
64
- ' @param {object} newValue - New observer configuration
65
- ' @param {object} oldValue - Previous observer configuration
41
+ ' Rooibos code coverage instrumentation (tested up to v6.0.0-alpha.50)
42
+ ' reorders the compiled constructor so that class-level field initializers
43
+ ' are placed BEFORE the super() call. Since BasePlugin's constructor sets
44
+ ' m.hooks = invalid, this causes the parent to overwrite the hooks AA that
45
+ ' was already set by the field initializer.
66
46
  '
67
- beforeUpdate: sub(scope as object, widget as object, newValue, oldValue = {})
68
- if oldValue <> invalid
47
+ ' Only affects classes with an explicit sub new() + super() AND
48
+ ' class-level field initializers. Other plugins (FieldsPlugin, FocusPlugin,
49
+ ' etc.) are not affected because they have no explicit constructor.
50
+ m.hooks = {
51
+ beforeMount: sub(scope as object, widget as object)
52
+ config = widget[scope.pluginKey]
53
+ scope.attach(widget.node, config, widget)
54
+ end sub,
55
+ beforeUpdate: sub(scope as object, widget as object, newValue, oldValue = {})
56
+ if oldValue <> invalid
57
+ scope.detach(widget.node)
58
+ end if
59
+ widget[scope.pluginKey] = newValue
60
+ scope.attach(widget.node, newValue, widget)
61
+ end sub,
62
+ beforeDestroy: sub(scope as object, widget as object)
69
63
  scope.detach(widget.node)
70
- end if
71
- widget[scope.pluginKey] = newValue
72
- scope.attach(widget.node, newValue, widget)
73
- end sub,
74
-
75
- ' ---------------------------------------------------------------------
76
- ' beforeDestroy - Detaches all observers before widget destruction
77
- '
78
- ' Ensures cleanup of all observers when widget is removed from scene graph.
79
- '
80
- ' @param {object} scope - Plugin instance (m)
81
- ' @param {object} widget - Widget being destroyed
82
- '
83
- beforeDestroy: sub(scope as object, widget as object)
84
- scope.detach(widget.node)
85
- end sub
86
- }
64
+ end sub
65
+ }
66
+ end sub
87
67
 
88
68
  ' =============================================================
89
69
  ' INITIALIZATION
@@ -405,7 +385,7 @@ namespace Rotor
405
385
  ' Represents a single observer configuration for a node field.
406
386
  '
407
387
  ' Responsibilities:
408
- ' - Stores observer configuration (callback, conditions, etc.)
388
+ ' - Stores observer configuration (callback or handler, conditions, etc.)
409
389
  ' - Sets up initial field value if provided
410
390
  ' - Provides info fields for observeFieldScoped
411
391
  ' - Executes callbacks in correct scope
@@ -427,7 +407,8 @@ namespace Rotor
427
407
  value as dynamic ' Initial field value (if any)
428
408
  once as boolean ' Remove observer after first trigger
429
409
  until as function ' Conditional removal function
430
- callback as function ' Callback function to execute
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)
431
412
  parsePayload as function ' Payload transformation function
432
413
  alwaysNotify as boolean ' Field alwaysNotify flag
433
414
 
@@ -462,9 +443,13 @@ namespace Rotor
462
443
  m.once = config?.once ?? false
463
444
  m.until = config?.until
464
445
 
465
- ' Set callback (required)
466
- m.callback = config?.callback ?? sub() throw "Callback has not configured for observer"
467
- end Sub
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
468
453
 
469
454
  ' Set payload parser (optional)
470
455
  m.parsePayload = config?.parsePayload ?? function(payload)
@@ -518,14 +503,19 @@ namespace Rotor
518
503
  ' =============================================================
519
504
 
520
505
  ' ---------------------------------------------------------------------
521
- ' notify - Executes observer callback
506
+ ' notify - Executes observer callback or handler
522
507
  '
523
- ' Calls the configured callback function in the correct scope with the payload.
508
+ ' Calls the configured callback (with payload) or handler (without arguments)
509
+ ' in the correct widget scope.
524
510
  '
525
- ' @param {dynamic} payload - Data to pass to callback
511
+ ' @param {dynamic} payload - Data to pass to callback (ignored for handler)
526
512
  '
527
513
  sub notify(payload as dynamic)
528
- Rotor.Utils.callbackScoped(m.callback, m.listenerScope, payload)
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
529
519
  end sub
530
520
 
531
521
  ' =============================================================