• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mobalazs / rotor-framework / 18919729013

29 Oct 2025 07:21PM UTC coverage: 85.379% (-0.1%) from 85.479%
18919729013

push

github

mobalazs
fix: update debug setting in bsconfig to enable debugging

1781 of 2086 relevant lines covered (85.38%)

1.16 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.95
/src/source/plugins/ObserverPlugin.bs
1
namespace Rotor
2

3
    ' =====================================================================
4
    ' ObserverPlugin - Field change observation for roSGNodes
5
    '
6
    ' Rotor Framework plugin for observing field changes on roSGNodes.
7
    ' Manages observer registration, callback routing, and cleanup through
8
    ' widget lifecycle hooks.
9
    '
10
    ' Key Responsibilities:
11
    '   - Attaches/detaches observers to widget nodes during lifecycle
12
    '   - Routes native SceneGraph field change callbacks to registered observers
13
    '   - Manages observer lifecycle (once, until conditions)
14
    '   - Provides unique attachment IDs for tracking observers per node
15
    ' =====================================================================
16
    class ObserverPlugin extends Rotor.BasePlugin
17

18
        ' =============================================================
19
        ' MEMBER VARIABLES
20
        ' =============================================================
21

22
        observerStack as object         ' ObserverStack instance managing all active observers
23
        helperInterfaceId as object     ' Unique field ID used to store plugin metadata on nodes
24

25
        ' =============================================================
26
        ' CONSTRUCTOR
27
        ' =============================================================
28

29
        ' ---------------------------------------------------------------------
30
        ' new - Initializes the ObserverPlugin instance
31
        '
32
        ' @param {string} key - Plugin identifier (default: "observer")
33
        '
34
        sub new(key = "observer")
35
            super(key)
1✔
36
        end sub
37

38
        ' =============================================================
39
        ' LIFECYCLE HOOKS
40
        ' =============================================================
41

42
        hooks = {
43
            ' ---------------------------------------------------------------------
44
            ' beforeMount - Attaches observers when widget is mounted
45
            '
46
            ' Called during widget creation to register all observers defined in the widget config.
47
            '
48
            ' @param {object} scope - Plugin instance (m)
49
            ' @param {object} widget - Widget being mounted
50
            '
51
            beforeMount: sub(scope as object, widget as object)
52
                config = widget[scope.key]
1✔
53
                scope.attach(widget.node, config, widget)
1✔
54
            end sub,
55

56
            ' ---------------------------------------------------------------------
57
            ' beforeUpdate - Updates observers when widget config changes
58
            '
59
            ' Detaches old observers and attaches new ones based on updated configuration.
60
            '
61
            ' @param {object} scope - Plugin instance (m)
62
            ' @param {object} widget - Widget being updated
63
            ' @param {object} newValue - New observer configuration
64
            ' @param {object} oldValue - Previous observer configuration
65
            '
66
            beforeUpdate: sub(scope as object, widget as object, newValue, oldValue = {})
67
                if oldValue <> invalid
×
68
                    scope.detach(widget.node)
×
69
                end if
70
                widget[scope.key] = newValue
×
71
                scope.attach(widget.node, newValue, widget)
×
72
            end sub,
73

74
            ' ---------------------------------------------------------------------
75
            ' beforeDestroy - Detaches all observers before widget destruction
76
            '
77
            ' Ensures cleanup of all observers when widget is removed from scene graph.
78
            '
79
            ' @param {object} scope - Plugin instance (m)
80
            ' @param {object} widget - Widget being destroyed
81
            '
82
            beforeDestroy: sub(scope as object, widget as object)
83
                scope.detach(widget.node)
1✔
84
            end sub
85
        }
86

87
        ' =============================================================
88
        ' INITIALIZATION
89
        ' =============================================================
90

91
        ' ---------------------------------------------------------------------
92
        ' init - Initializes plugin internal state
93
        '
94
        ' Creates the observer stack and sets up the helper interface ID
95
        ' used to track observers on nodes.
96
        '
97
        sub init()
98
            m.observerStack = new Rotor.ObserverPluginHelper.ObserverStack()
1✔
99
            m.helperInterfaceId = Rotor.ObserverPluginHelper.OBSERVER_HELPER_INTERFACE + "-" + m.key
1✔
100
        end sub
101

102
        ' =============================================================
103
        ' OBSERVER MANAGEMENT
104
        ' =============================================================
105

106
        ' ---------------------------------------------------------------------
107
        ' attach - Attaches observers to a node
108
        '
109
        ' Sets up helper interface on the node (if not already present) and registers
110
        ' all observers defined in the configuration.
111
        '
112
        ' @param {object} node - roSGNode to attach observers to
113
        ' @param {object} config - Observer configuration (single object or array)
114
        ' @param {object} listenerScope - Widget instance for callback execution
115
        '
116
        sub attach(node as object, config as object, listenerScope as object)
117
            ' Determine or create attachment ID for this node
118
            attachmentId = invalid
1✔
119
            if node.hasField(m.helperInterfaceId)
2✔
120
                ' Node already has helper interface - reuse existing attachmentId
121
                pluginHelperValue = node.getField(m.helperInterfaceId)
×
122
                attachmentId = pluginHelperValue.attachmentId
×
123
            else
124
                ' First time attaching to this node - create new attachmentId
3✔
125
                attachmentId = Rotor.Utils.getUUIDHex()
1✔
126

127
                if node <> invalid
3✔
128
                    ' Add helper interface field to node with plugin metadata
129
                    pluginHelperFields = Rotor.Utils.wrapObject(m.helperInterfaceId, {
1✔
130
                        pluginKey: m.key,
131
                        attachmentId: attachmentId
132
                    })
133
                    Rotor.Utils.setCustomFields(node, pluginHelperFields, true, false)
1✔
134
                end if
135
            end if
136

137
            ' Register each observer configuration
138
            if config <> invalid and config.Count() > 0 and attachmentId <> invalid
3✔
139
                observerConfigs = Rotor.Utils.ensureArray(config)
1✔
140
                for each observerConfig in observerConfigs
1✔
141
                    m.registerObserver(observerConfig, node, attachmentId, m.helperInterfaceId, listenerScope)
1✔
142
                end for
143
            end if
144
        end sub
145

146
        ' ---------------------------------------------------------------------
147
        ' registerObserver - Creates and registers a single observer instance
148
        '
149
        ' Creates an Observer object, stores it in the stack, and sets up the native
150
        ' SceneGraph observeFieldScoped call.
151
        '
152
        ' @param {object} observerConfig - Configuration for the specific observer
153
        ' @param {object} node - roSGNode being observed
154
        ' @param {string} attachmentId - Unique ID linking observers to this node
155
        ' @param {string} helperInterfaceId - Helper interface field ID
156
        ' @param {object} listenerScope - Widget scope for callback execution
157
        '
158
        sub registerObserver(observerConfig as object, node as object, attachmentId as string, helperInterfaceId as string, listenerScope as object)
159
            ' Create observer instance
160
            newObserver = new Rotor.ObserverPluginHelper.Observer(observerConfig, node, attachmentId, listenerScope, m.key)
1✔
161
            m.observerStack.set(newObserver.id, newObserver)
1✔
162

163
            ' Set up native SceneGraph observation
164
            fieldId = observerConfig.fieldId
1✔
165
            infoFields = newObserver.getInfoFields()
1✔
166
            node.observeFieldScoped(fieldId, "Rotor_ObserverPluginHelper_observerNativeCallback", infoFields)
1✔
167
        end sub
168

169
        ' ---------------------------------------------------------------------
170
        ' detach - Removes all observers associated with a node
171
        '
172
        ' Unobserves all fields and removes observer instances from the stack.
173
        '
174
        ' @param {dynamic} node - roSGNode to detach observers from
175
        '
176
        sub detach(node as dynamic)
177
            ' Get plugin helper metadata from node
178
            pluginHelperValue = node.getField(m.helperInterfaceId)
1✔
179
            if pluginHelperValue = invalid then return
2✔
180

181
            attachmentId = pluginHelperValue.attachmentId
1✔
182
            if attachmentId = invalid then return
2✔
183

184
            ' Find and remove all observers for this attachment
185
            observers = m.observerStack.findObserverByAttachmentId(attachmentId)
1✔
186
            for each observer in observers
1✔
187
                if observer.node <> invalid
3✔
188
                    observer.node.unobserveFieldScoped(observer.fieldId)
1✔
189
                end if
190
                m.observerStack.remove(observer.id) ' Calls observer.destroy()
1✔
191
            end for
192
        end sub
193

194
        ' =============================================================
195
        ' CALLBACK ROUTING
196
        ' =============================================================
197

198
        ' ---------------------------------------------------------------------
199
        ' observerCallbackRouter - Routes native callback to appropriate observers
200
        '
201
        ' Called by the global observerNativeCallback when a field changes.
202
        ' Finds matching observers and triggers their callbacks.
203
        '
204
        ' Handles:
205
        '   - Payload parsing via custom parsePayload functions
206
        '   - 'once' observers (removed after first trigger)
207
        '   - 'until' condition observers (removed when condition met)
208
        '
209
        ' @param {dynamic} value - New value of the observed field
210
        ' @param {object} extraInfo - Additional info from observeFieldScoped
211
        ' @param {string} fieldId - ID of the field that changed
212
        ' @param {string} attachmentId - Node attachment ID
213
        ' @param {string} pluginKey - Plugin instance key
214
        '
215
        sub observerCallbackRouter(value as dynamic, extraInfo as object, fieldId as string, attachmentId as string, pluginKey as string)
216
            ' Find all observers interested in this field change
217
            interestedObservers = m.observerStack.findObserversByAttachmentAndField(attachmentId, fieldId)
1✔
218

219
            for each observer in interestedObservers
1✔
220
                ' Build and parse payload
221
                payload = Rotor.Utils.wrapObject(fieldId, value)
1✔
222
                payload.append(extraInfo)
1✔
223
                parsedPayload = observer.parsePayload(payload)
1✔
224

225
                ' Execute observer callback
226
                observer.notify(parsedPayload)
1✔
227

228
                ' Handle observer removal conditions
229
                if observer.once = true or (Rotor.Utils.isFunction(observer.until) and true = observer.until(parsedPayload))
2✔
230
                    ' Unobserve before removing to prevent race conditions
231
                    if observer.node <> invalid
3✔
232
                        observer.node.unobserveFieldScoped(observer.fieldId)
1✔
233
                    end if
234
                    m.observerStack.remove(observer.id)
1✔
235
                end if
236
            end for
237
        end sub
238

239
        ' =============================================================
240
        ' CLEANUP
241
        ' =============================================================
242

243
        ' ---------------------------------------------------------------------
244
        ' destroy - Cleans up all observers
245
        '
246
        ' Called when the framework instance is destroyed.
247
        ' Unobserves all fields and removes all observer instances.
248
        '
249
        sub destroy()
250
            if m.observerStack = invalid then return
×
251

252
            ' Collect observer IDs to avoid mutation during iteration
253
            ids = []
×
254
            all = m.observerStack.getAll()
×
255
            for each id in all
×
256
                observer = all[id]
×
257
                if observer <> invalid and observer.node <> invalid
×
258
                    observer.node.unobserveFieldScoped(observer.fieldId)
×
259
                end if
260
                ids.push(id)
×
261
            end for
262

263
            ' Remove all observers
264
            for each id in ids
×
265
                m.observerStack.remove(id) ' Calls observer.destroy()
×
266
            end for
267

268
            ' Clear the stack
269
            m.observerStack.clear()
×
270
        end sub
271

272
    end class
273

274
    ' =================================================================
275
    ' OBSERVER PLUGIN HELPER NAMESPACE
276
    ' =================================================================
277

278
    namespace ObserverPluginHelper
279

280
        ' Prefix for helper field on nodes
281
        const OBSERVER_HELPER_INTERFACE = "rotorObserverPluginKeysHelper"
282

283
        ' ---------------------------------------------------------------------
284
        ' observerNativeCallback - Global callback for SceneGraph field changes
285
        '
286
        ' This function is registered with node.observeFieldScoped() and is called
287
        ' whenever an observed field changes. It extracts metadata, identifies
288
        ' the appropriate plugin instance, and routes to observerCallbackRouter.
289
        '
290
        ' @param {object} msg - roSGNodeEvent from the observed node
291
        '
292
        sub observerNativeCallback(msg as object)
293
            ' Extract event data
294
            extraInfo = msg.GetInfo()
1✔
295
            fieldId = msg.getField()
1✔
296
            value = msg.getData()
1✔
297

298
            ' Extract plugin metadata from helper field
299
            pluginKey = ""
1✔
300
            attachmentId = ""
1✔
301
            for each key in extraInfo
1✔
302
                if Left(key, Len(OBSERVER_HELPER_INTERFACE)) = OBSERVER_HELPER_INTERFACE
3✔
303
                    helperValue = extraInfo[key]
1✔
304
                    if helperValue <> invalid
3✔
305
                        pluginKey = helperValue.pluginKey
1✔
306
                        attachmentId = helperValue.attachmentId
1✔
307
                    end if
308
                    extraInfo.delete(key) ' Remove helper from payload
1✔
309
                    exit for
310
                end if
311
            end for
312

313
            ' Route to appropriate plugin instance
314
            if attachmentId <> "" and pluginKey <> ""
3✔
315
                globalScope = GetGlobalAA()
1✔
316
                frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
1✔
317
                plugin = invalid
1✔
318

319
                ' Handle special case for Rotor Animator observers
320
                if extraInfo?.isRotorAnimatorNode = true
2✔
321
                    plugin = frameworkInstance.animatorProvider.animatorObservber
1✔
322
                else
3✔
323
                    plugin = frameworkInstance.plugins[pluginKey]
1✔
324
                end if
325

326
                ' Execute callback router
327
                if plugin <> invalid
3✔
328
                    plugin.observerCallbackRouter(value, extraInfo, fieldId, attachmentId, pluginKey)
1✔
329
                end if
330
            end if
331
        end sub
332

333
        ' =====================================================================
334
        ' ObserverStack - Collection manager for Observer instances
335
        '
336
        ' Manages a collection of Observer instances with specialized lookup methods.
337
        '
338
        ' Provides:
339
        '   - Storage and retrieval of observers by ID
340
        '   - Lookup by attachment ID and field ID
341
        '   - Automatic cleanup on removal
342
        ' =====================================================================
343
        class ObserverStack extends Rotor.BaseStack
344

345
            ' ---------------------------------------------------------------------
346
            ' remove - Removes observer and triggers cleanup
347
            '
348
            ' Overrides base class to call observer.destroy() before removal.
349
            '
350
            ' @param {string} id - Observer ID to remove
351
            '
352
            override sub remove(id as string)
353
                item = m.get(id)
1✔
354
                if item <> invalid
3✔
355
                    item.destroy()
1✔
356
                end if
357
                super.remove(id)
1✔
358
            end sub
359

360
            ' ---------------------------------------------------------------------
361
            ' findObserversByAttachmentAndField - Finds observers by attachment and field
362
            '
363
            ' Returns all observers matching both the attachment ID and field ID.
364
            '
365
            ' @param {string} attachmentId - Node attachment ID
366
            ' @param {string} fieldId - Field ID
367
            ' @returns {object} Array of matching Observer instances
368
            '
369
            function findObserversByAttachmentAndField(attachmentId as string, fieldId as string) as object
370
                observers = []
1✔
371
                for each id in m.stack
1✔
372
                    observer = m.stack[id]
1✔
373
                    if observer.fieldId = fieldId and observer.attachmentId = attachmentId
3✔
374
                        observers.push(observer)
1✔
375
                    end if
376
                end for
377
                return observers
1✔
378
            end function
379

380
            ' ---------------------------------------------------------------------
381
            ' findObserverByAttachmentId - Finds all observers for an attachment
382
            '
383
            ' Returns all observers associated with a specific node attachment.
384
            '
385
            ' @param {string} attachmentId - Node attachment ID
386
            ' @returns {object} Array of matching Observer instances
387
            '
388
            function findObserverByAttachmentId(attachmentId as string) as object
389
                observers = []
1✔
390
                for each id in m.stack
1✔
391
                    observer = m.stack[id]
1✔
392
                    if observer.attachmentId = attachmentId
3✔
393
                        observers.push(observer)
1✔
394
                    end if
395
                end for
396
                return observers
1✔
397
            end function
398

399
        end class
400

401
        ' =====================================================================
402
        ' Observer - Single observer configuration for a node field
403
        '
404
        ' Represents a single observer configuration for a node field.
405
        '
406
        ' Responsibilities:
407
        '   - Stores observer configuration (callback, conditions, etc.)
408
        '   - Sets up initial field value if provided
409
        '   - Provides info fields for observeFieldScoped
410
        '   - Executes callbacks in correct scope
411
        '   - Manages cleanup of references
412
        ' =====================================================================
413
        class Observer
414

415
            ' =============================================================
416
            ' MEMBER VARIABLES
417
            ' =============================================================
418

419
            id as string                ' Unique observer ID
420
            node as object              ' roSGNode being observed
421
            pluginKey as string         ' Key of managing ObserverPlugin
422
            listenerScope as object     ' Widget scope for callback execution
423
            attachmentId as string      ' Node attachment ID
424
            fieldId as string           ' Field name being observed
425
            infoFields as object        ' Additional fields to include in callback info
426
            value as dynamic            ' Initial field value (if any)
427
            once as boolean             ' Remove observer after first trigger
428
            until as function           ' Conditional removal function
429
            callback as function        ' Callback function to execute
430
            parsePayload as function    ' Payload transformation function
431
            alwaysNotify as boolean     ' Field alwaysNotify flag
432

433
            ' =============================================================
434
            ' CONSTRUCTOR
435
            ' =============================================================
436

437
            ' ---------------------------------------------------------------------
438
            ' new - Creates an Observer instance
439
            '
440
            ' @param {object} config - Observer configuration
441
            ' @param {object} node - roSGNode to observe
442
            ' @param {string} attachmentId - Node attachment ID
443
            ' @param {object} listenerScope - Widget scope for callbacks
444
            ' @param {string} pluginKey - Managing plugin key
445
            '
446
            sub new(config as object, node as object, attachmentId as string, listenerScope as object, pluginKey as string)
447
                ' Generate unique ID
448
                m.id = (config.id ?? "ID") + "-" + Rotor.Utils.getUUIDHex()
1✔
449

450
                ' Store references
451
                m.node = node
1✔
452
                m.pluginKey = pluginKey
1✔
453
                m.listenerScope = listenerScope ?? {}
1✔
454
                m.attachmentId = attachmentId
1✔
455

456
                ' Extract configuration
457
                m.fieldId = config?.fieldId ?? ""
1✔
458
                m.infoFields = config?.infoFields ?? []
1✔
459
                m.value = config?.value
1✔
460
                m.alwaysNotify = config?.alwaysNotify ?? true
1✔
461
                m.once = config?.once ?? false
1✔
462
                m.until = config?.until
1✔
463

464
                ' Set callback (required)
465
                m.callback = config?.callback ?? sub() throw "Callback has not configured for observer"
1✔
466
                end Sub
467

468
                ' Set payload parser (optional)
469
                m.parsePayload = config?.parsePayload ?? function(payload)
1✔
470
                    return payload
471
                end function
472

473
                ' Set up initial field value if provided
474
                m.setupField(m.fieldId, m.value, m.alwaysNotify)
1✔
475
            end sub
476

477
            ' =============================================================
478
            ' FIELD SETUP
479
            ' =============================================================
480

481
            ' ---------------------------------------------------------------------
482
            ' setupField - Sets initial value on observed field
483
            '
484
            ' Creates or updates the field on the node if an initial value is provided.
485
            '
486
            ' @param {string} fieldId - Field name
487
            ' @param {dynamic} value - Initial value (if not invalid)
488
            ' @param {boolean} alwaysNotify - alwaysNotify flag value
489
            '
490
            sub setupField(fieldId as string, value as dynamic, alwaysNotify as boolean)
491
                fields = {}
1✔
492
                fields[m.fieldId] = value
1✔
493
                Rotor.Utils.setCustomFields(m.node, fields, m.value <> invalid, alwaysNotify)
1✔
494
            end sub
495

496
            ' =============================================================
497
            ' INFO FIELDS
498
            ' =============================================================
499

500
            ' ---------------------------------------------------------------------
501
            ' getInfoFields - Builds field list for observeFieldScoped
502
            '
503
            ' Combines user-defined infoFields with the helper interface ID.
504
            '
505
            ' @returns {object} Array of field names for observeFieldScoped
506
            '
507
            function getInfoFields() as object
508
                helperInterfaceId = Rotor.ObserverPluginHelper.OBSERVER_HELPER_INTERFACE + "-" + m.pluginKey
1✔
509
                infoFields = []
1✔
510
                infoFields.append(m.infoFields)
1✔
511
                infoFields.push(helperInterfaceId)
1✔
512
                return infoFields
1✔
513
            end function
514

515
            ' =============================================================
516
            ' CALLBACK EXECUTION
517
            ' =============================================================
518

519
            ' ---------------------------------------------------------------------
520
            ' notify - Executes observer callback
521
            '
522
            ' Calls the configured callback function in the correct scope with the payload.
523
            '
524
            ' @param {dynamic} payload - Data to pass to callback
525
            '
526
            sub notify(payload as dynamic)
527
                Rotor.Utils.callbackScoped(m.callback, m.listenerScope, payload)
1✔
528
            end sub
529

530
            ' =============================================================
531
            ' CLEANUP
532
            ' =============================================================
533

534
            ' ---------------------------------------------------------------------
535
            ' destroy - Cleans up observer references
536
            '
537
            ' Clears references to prevent memory leaks.
538
            '
539
            sub destroy()
540
                m.node = invalid
1✔
541
                m.listenerScope = invalid
1✔
542
            end sub
543

544
        end class
545

546
    end namespace ' ObserverPluginHelper
547

548
end namespace ' Rotor
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc