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

mobalazs / rotor-framework / 25910768822

15 May 2026 09:32AM UTC coverage: 90.133% (+0.2%) from 89.933%
25910768822

push

github

web-flow
Feat/rsg 1.3 os15 upgrade (#25)

Summary
This PR updates the dispatcher architecture to support RSG 1.3, introducing improved task synchronization and state management across threads.

Key Changes
Dispatcher refactor – Updated dispatcher methods and routing logic for RSG 1.3 compatibility
New queue system – Implemented syncQueue and renderQueue for better cross-thread task coordination
State management – Enhanced state handling in the cross-thread dispatcher
Documentation – Updated framework initialization docs, pitfalls guide, injectedMethods reference, and widget API (erase method signature)
CI fix – Updated workflow condition to skip tag pushes
Why
The previous dispatcher implementation wasn't compatible with RSG 1.3's threading model. The new queue-based approach ensures proper task synchronization between render and task threads.

48 of 49 new or added lines in 4 files covered. (97.96%)

4 existing lines in 3 files now uncovered.

2165 of 2402 relevant lines covered (90.13%)

2.5 hits per line

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

84.15
/src/source/plugins/FocusPlugin.bs
1
' TODO: Future improvement: integrate deviceinfo TimeSinceLastKeypress() -> idle time
2
' TODO: Future improvement: key combo detector
3

4
namespace Rotor
5

6
    ' =====================================================================
7
    ' FocusPlugin - Handles focus logic, focus groups, and spatial navigation
8
    '
9
    ' A Brighterscript class for handling focus logic, focus groups,
10
    ' and spatial navigation within the Rotor framework.
11
    '
12
    ' ═══════════════════════════════════════════════════════════════
13
    ' CONCEPTUAL OVERVIEW: BUBBLING vs CAPTURING
14
    ' ═══════════════════════════════════════════════════════════════
15
    '
16
    ' This plugin implements two complementary focus resolution strategies:
17
    '
18
    ' 1. BUBBLING FOCUS (bubblingFocus) - "Upward Search"
19
    '    ┌─────────────────────────────────────────────────────────┐
20
    '    │ WHEN: User interaction (key press) cannot find target   │
21
    '    │ DIRECTION: Child → Parent → Grandparent (upward)        │
22
    '    │ PURPOSE: "I can't navigate further, ask my parents"     │
23
    '    └─────────────────────────────────────────────────────────┘
24
    '
25
    '    Example: User presses UP from a focused item, but there's
26
    '    no item above. The plugin "bubbles up" through ancestor
27
    '    groups to find an alternative navigation path defined at
28
    '    a higher level.
29
    '
30
    ' 2. CAPTURING FOCUS (capturingFocus_recursively) - "Downward Rescue"
31
    '    ┌─────────────────────────────────────────────────────────┐
32
    '    │ WHEN: Need to resolve abstract target to concrete item  │
33
    '    │ DIRECTION: Group → Nested Group → FocusItem (downward)  │
34
    '    │ PURPOSE: "Found a group/ID, find the actual item"       │
35
    '    └─────────────────────────────────────────────────────────┘
36
    '
37
    '    This is a "rescue operation" that converts:
38
    '    - Group reference → concrete FocusItem
39
    '    - ID string → actual widget with focus capability
40
    '
41
    '    Example: Bubbling found "menuGroup", but we need a specific
42
    '    focusable item. Capturing recursively descends through the
43
    '    group's defaultFocusId chain until it finds a real FocusItem.
44
    '
45
    '
46
    ' DEEP SEARCH ENHANCEMENT:
47
    '    The capturing process now searches deeply in hierarchies.
48
    '    If defaultFocusId doesn't match immediate children, it will:
49
    '    - Search all descendant FocusItems (any depth)
50
    '    - Search all nested Groups (any depth)
51
    '    - Apply fallback logic if a matching Group is found
52
    '
53
    '    This means defaultFocusId: "deepItem" will find "deepItem"
54
    '    even if it's 3+ levels deep in the hierarchy!
55
    '
56
    ' TOGETHER THEY WORK AS:
57
    '    User Action → Bubbling (↑ find alternative) → Capturing (↓ resolve target)
58
    '
59
    ' ═══════════════════════════════════════════════════════════════
60
    ' COMPLETE RULES REFERENCE
61
    ' ═══════════════════════════════════════════════════════════════
62
    '
63
    ' RULE #1: Widget Types
64
    '   - focus: { group: {...} } → Group (container)
65
    '   - focus: {...} (no group key) → FocusItem (focusable element)
66
    '   - No focus config → Not part of focus system
67
    '
68
    ' RULE #2: FocusItem Direction Values
69
    '   - String (Node ID): Static navigation to that element
70
    '   - Function: Dynamic, evaluated at runtime
71
    '   - false: Blocks the direction (nothing happens)
72
    '   - true/undefined/empty string: Spatial navigation attempts
73
    '
74
    ' RULE #3: Navigation Priority (Decreasing Order)
75
    '   1. FocusItem static direction (left: "button2")
76
    '   2. Spatial navigation (within group only)
77
    '   3. BubblingFocus (ask parent groups)
78
    '
79
    ' RULE #4: Spatial Navigation Scope
80
    '   - Works within parent group scope
81
    '   - Participants: FocusItems AND direct child Groups with enableSpatialNavigation: true
82
    '   - enableSpatialNavigation default is FALSE (opt-in)
83
    '   - When Group is selected via spatial nav, capturing focus starts into that group
84
    '   - Searches possibleFocusItems + Groups from group.getGroupMembersHIDs()
85
    '
86
    ' RULE #5: Group Direction Activation
87
    '   Group direction triggers ONLY when:
88
    '   - FocusItem has NO static direction
89
    '   - Spatial navigation found NOTHING
90
    '   - BubblingFocus reaches this group
91
    '
92
    ' RULE #6: Group Direction Values
93
    '   - String (Node ID): Navigate to that group/item (may EXIT group)
94
    '   - true: BLOCKS (stays on current element)
95
    '   - false/undefined: Continue bubbling to next ancestor
96
    '
97
    ' RULE #7: Group Direction Does NOT Block Spatial Navigation
98
    '   Setting group.right = true does NOT prevent spatial navigation
99
    '   INSIDE the group. It only blocks EXITING the group when spatial
100
    '   navigation finds nothing.
101
    '
102
    ' RULE #8: Exiting a Group - 3 Methods
103
    '   Method 1: FocusItem explicit direction
104
    '     focusItem.right = "otherGroupItem" → EXITS immediately
105
    '   Method 2: Group direction (via BubblingFocus)
106
    '     group.right = "otherGroup" → EXITS when spatial nav fails
107
    '   Method 3: Ancestor group direction
108
    '     parentGroup.right = "otherGroup" → EXITS when child groups pass
109
    '
110
    ' RULE #9: Blocking Group Exit
111
    '   To prevent exit: group.left = true, group.right = true
112
    '   Exception: FocusItem explicit directions still work!
113
    '
114
    ' RULE #10: BubblingFocus Flow
115
    '   FocusItem (no direction) → Spatial nav (nothing) → Group.direction?
116
    '     - "nodeId" → CapturingFocus(nodeId) [EXIT]
117
    '     - true → STOP (stay on current)
118
    '     - false/undefined → Continue to parent group
119
    '     - No more ancestors → Stay on current
120
    '
121
    ' RULE #11: CapturingFocus Priority
122
    '   1. group.lastFocusedHID (if exists) [AUTO-SAVED]
123
    '   2. group.defaultFocusId [CONFIGURED]
124
    '   3. Deep search (if defaultFocusId not found immediately)
125
    '
126
    ' RULE #12: DefaultFocusId Targets
127
    '   - FocusItem node ID → Focus goes directly to it
128
    '   - Group node ID → Capturing continues on that group
129
    '   - Non-existent ID → Deep search attempts
130
    '
131
    ' RULE #13: Deep Search Activation
132
    '   Triggers when:
133
    '   - CapturingFocus doesn't find defaultFocusId in immediate children
134
    '   - defaultFocusId is not empty
135
    '   Searches:
136
    '   1. All descendant FocusItems (any depth)
137
    '   2. All nested Groups (any depth, applies their fallback)
138
    '
139
    ' RULE #14: Spatial Enter
140
    '   When enableSpatialEnter = true on a group:
141
    '   - Entering the group uses spatial navigation from the direction
142
    '   - Finds geometrically closest item instead of defaultFocusId
143
    '   - Falls back to defaultFocusId if spatial finds nothing
144
    '
145
    ' RULE #15: Navigation Decision Tree Summary
146
    '   User presses direction key:
147
    '     1. FocusItem.direction exists? → Use it (may EXIT group)
148
    '     2. Spatial nav finds item? → Navigate (STAYS in group)
149
    '     3. BubblingFocus: Group.direction?
150
    '        - "nodeId" → EXIT to that target
151
    '        - true → BLOCK (stay)
152
    '        - undefined → Continue to ancestor
153
    '     4. No more ancestors? → STAY on current item
154
    '
155
    ' COMMON PATTERNS:
156
    '   Sidebar + Content:
157
    '     sidebar: { group: { right: true } }
158
    '     menuItem1: { right: "contentFirst" } [explicit exit]
159
    '
160
    '   Modal Dialog (locked):
161
    '     modal: { group: { left: true, right: true, up: true, down: true } }
162
    '
163
    '   Nested Navigation:
164
    '     innerGroup: { group: { down: undefined } } [no direction]
165
    '     outerGroup: { group: { down: "bottomBar" } } [catches bubbling]
166
    '
167
    ' =====================================================================
168

169
    const PRIMARY_FOCUS_PLUGIN_KEY = "focus"
170
    const GROUP_FOCUS_PLUGIN_KEY = "focusGroup"
171
    class FocusPlugin extends Rotor.BasePlugin
172

173
        pluginKey = PRIMARY_FOCUS_PLUGIN_KEY
174
        aliasPluginKey = GROUP_FOCUS_PLUGIN_KEY
175

176
        ' Framework lifecycle hooks
177
        hooks = {
178
            ' ---------------------------------------------------------------------
179
            ' beforeMount - Hook executed before a widget is mounted
180
            '
181
            ' Sets initial focus config.
182
            '
183
            ' @param {object} scope - The plugin scope (this instance)
184
            ' @param {object} widget - The widget being mounted
185
            '
186
            beforeMount: sub(scope as object, widget as object)
187
                ' Validation: widget cannot have both "focus" and GROUP_FOCUS_PLUGIN_KEY configs
188
                isFocusItem = widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY) and widget.focus <> invalid
2✔
189
                isFocusGroup = widget.DoesExist(GROUP_FOCUS_PLUGIN_KEY) and widget.focusGroup <> invalid
2✔
190

191
                if isFocusItem and isFocusGroup
4✔
192
                    #if debug
8✔
193
                        ? "[FOCUS_PLUGIN][ERROR] Widget '" + widget.id + "' (HID: " + widget.HID + ") cannot have both 'focus' and 'focusGroup' configurations!"
2✔
194
                    #end if
195
                    return ' Skip setup for this widget
2✔
196
                end if
197

198
                config = widget[isFocusItem ? PRIMARY_FOCUS_PLUGIN_KEY : GROUP_FOCUS_PLUGIN_KEY]
2✔
199
                scope.setFocusConfig(widget, config)
2✔
200
            end sub,
201

202
            ' ---------------------------------------------------------------------
203
            ' beforeUpdate - Hook executed before a widget is updated
204
            '
205
            ' Removes old config, applies new.
206
            '
207
            ' @param {object} scope - The plugin scope (this instance)
208
            ' @param {object} widget - The widget being updated
209
            ' @param {dynamic} newValue - The new plugin configuration value
210
            ' @param {object} oldValue - The previous plugin configuration value (default: {})
211
            '
212
            beforeUpdate: sub(scope as object, widget as object, newValue, oldValue = {})
213
                ' Remove previous config before applying the update
214
                scope.removeFocusConfig(widget.HID)
2✔
215

216
                ' Determine whether this widget is a focus item or focus group
217
                targetKey = PRIMARY_FOCUS_PLUGIN_KEY
2✔
218
                if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY) and widget[PRIMARY_FOCUS_PLUGIN_KEY] <> invalid
6✔
219
                    targetKey = PRIMARY_FOCUS_PLUGIN_KEY
2✔
220
                else
6✔
221
                    targetKey = GROUP_FOCUS_PLUGIN_KEY
2✔
222
                end if
223

224
                ' Ensure target config exists
225
                if not Rotor.Utils.isAssociativeArray(widget[targetKey])
4✔
226
                    widget[targetKey] = {}
×
227
                end if
228

229
                ' Merge new config into existing widget config (or replace if non-AA)
230
                if Rotor.Utils.isAssociativeArray(newValue)
6✔
231
                    Rotor.Utils.deepExtendAA(widget[targetKey], newValue)
2✔
232
                else
×
233
                    widget[targetKey] = newValue
×
234
                end if
235

236
                scope.setFocusConfig(widget, widget[targetKey])
2✔
237
            end sub,
238

239
            ' ---------------------------------------------------------------------
240
            ' beforeDestroy - Hook executed before a widget is destroyed
241
            '
242
            ' Removes focus config. Clears global focus if this widget had it.
243
            '
244
            ' @param {object} scope - The plugin scope (this instance)
245
            ' @param {object} widget - The widget being destroyed
246
            '
247
            beforeDestroy: sub(scope as object, widget as object)
248
                hadFocus = scope.globalFocusHID = widget.HID
2✔
249
                scope.removeFocusConfig(widget.HID)
2✔
250
                if hadFocus
6✔
251
                    scope.storeGlobalFocusHID("", "")
2✔
252
                end if
253
            end sub
254
        }
255

256
        ' Widget methods - Injected into widgets managed by this plugin
257
        widgetMethods = {
258

259
            ' ---------------------------------------------------------------------
260
            ' enableFocusNavigation - Enables or disables focus navigation globally for this plugin
261
            '
262
            ' @param {boolean} enableFocusNavigation - True to enable, false to disable (default: true)
263
            '
264
            enableFocusNavigation: sub(enableFocusNavigation = true as boolean)
265
                m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].enableFocusNavigation = enableFocusNavigation
2✔
266
            end sub,
267

268
            ' ---------------------------------------------------------------------
269
            ' isFocusNavigationEnabled - Checks if focus navigation is currently enabled globally
270
            '
271
            ' @returns {boolean} True if enabled, false otherwise
272
            '
273
            isFocusNavigationEnabled: function() as boolean
274
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].enableFocusNavigation
2✔
275
            end function,
276

277
            ' ---------------------------------------------------------------------
278
            ' setFocus - Sets focus to this widget or another specified widget
279
            '
280
            ' @param {dynamic} isFocused - Boolean to focus/blur current widget, or string ID/HID of widget to focus
281
            ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node
282
            ' @returns {boolean} True if focus state was changed successfully, false otherwise
283
            '
284
            setFocus: function(command = true as dynamic, enableNativeFocus = false as boolean) as boolean
285
                plugin = m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY]
2✔
286
                HID = m.HID
2✔
287

288
                if Rotor.Utils.isString(command)
4✔
289
                    return plugin.setFocus(command, true, enableNativeFocus)
2✔
290
                else
6✔
291
                    return plugin.setFocus(HID, command, enableNativeFocus)
2✔
292
                end if
293
            end function,
294

295
            ' ---------------------------------------------------------------------
296
            ' getFocusedWidget - Retrieves the currently focused widget managed by this plugin
297
            '
298
            ' @returns {object} The widget instance that currently holds focus, or invalid
299
            '
300
            getFocusedWidget: function() as object
301
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].getFocusedWidget()
2✔
302
            end function,
303

304
            ' ---------------------------------------------------------------------
305
            ' proceedLongPress - Manually triggers the navigation action associated with the current long-press key
306
            '
307
            ' @returns {object} The result of the executed navigation action (see parseOnKeyEventResult)
308
            '
309
            proceedLongPress: function() as object
310
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].proceedLongPress()
2✔
311
            end function,
312

313
            ' ---------------------------------------------------------------------
314
            ' isLongPressActive - Checks if a long press action is currently active
315
            '
316
            ' @returns {boolean} True if a long press is active, false otherwise
317
            '
318
            isLongPressActive: function() as boolean
319
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].isLongPress
2✔
320
            end function,
321

322
            ' ---------------------------------------------------------------------
323
            ' triggerKeyPress - Simulate key press
324
            '
325
            ' @param {string} key - Pressed key
326
            ' @returns {object} The widget instance that currently holds focus, or invalid
327
            '
328
            triggerKeyPress: function(key) as object
329
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].onKeyEventHandler(key, true)
2✔
330
            end function,
331

332
            ' ---------------------------------------------------------------------
333
            ' setGroupLastFocusedId - Updates the lastFocusedHID of this widget's focus group
334
            '
335
            ' If called on a focusGroup widget, updates its own lastFocusedHID.
336
            ' If called on a focusItem widget, finds and updates the parent group's lastFocusedHID.
337
            '
338
            ' @param {string} id - The widget id to set as lastFocusedHID
339
            '
340
            setGroupLastFocusedId: sub(id as string)
341
                plugin = m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY]
2✔
342

343
                ' Determine ancestorHID for search context
344
                ' If this is a focus item, use parent group's HID to find siblings
345
                ancestorGroups = plugin.findAncestorGroups(m.HID)
2✔
346
                if ancestorGroups.count() > 0
4✔
347
                    ancestorHID = ancestorGroups[0]
2✔
348
                else
6✔
349
                    ancestorHID = m.HID
2✔
350
                end if
351

352
                ' Resolve id to HID - check focusItemStack first, then groupStack
353
                focusItem = plugin.focusItemStack.getByNodeId(id, ancestorHID)
2✔
354
                if focusItem <> invalid
4✔
355
                    resolvedHID = focusItem.HID
2✔
356
                else
6✔
357
                    group = plugin.groupStack.getByNodeId(id, ancestorHID)
2✔
358
                    if group <> invalid
6✔
359
                        resolvedHID = group.HID
2✔
360
                    else
6✔
361
                        return
2✔
362
                    end if
363
                end if
364

365
                ' Check if this widget is a group
366
                group = plugin.groupStack.get(m.HID)
2✔
367
                if group <> invalid
6✔
368
                    group.setLastFocusedHID(resolvedHID)
2✔
369
                    return
2✔
370
                end if
371

372
                ' This is a focus item - find parent group
373
                ancestorGroups = plugin.findAncestorGroups(m.HID)
2✔
374
                if ancestorGroups.count() > 0
6✔
375
                    parentGroupHID = ancestorGroups[0]
2✔
376
                    parentGroup = plugin.groupStack.get(parentGroupHID)
2✔
377
                    if parentGroup <> invalid
6✔
378
                        parentGroup.setLastFocusedHID(resolvedHID)
2✔
379
                    end if
380
                end if
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]
2✔
394
                focusItem = plugin.focusItemStack.get(m.HID)
2✔
395
                if focusItem <> invalid
6✔
396
                    return focusItem.isFocused
2✔
397
                end if
398
                group = plugin.groupStack.get(m.HID)
2✔
399
                if group <> invalid
6✔
400
                    return group.isFocused
2✔
401
                end if
402
                return false
×
403
            end function
404

405
        }
406

407
        ' Configuration
408
        longPressDuration = 0.4
409
        enableLongPressFeature = true
410
        enableFocusNavigation = true
411

412
        ' State tracking
413
        globalFocusHID = ""
414
        globalFocusId = ""
415
        lastNavigationDirection = ""
416
        isLongPress = false
417
        longPressKey = ""
418

419
        ' References
420
        widgetTree as object
421
        frameworkInstance as Rotor.Framework
422

423
        ' Helper objects
424
        focusItemStack = new Rotor.FocusPluginHelper.FocusItemStack()
425
        groupStack = new Rotor.FocusPluginHelper.GroupStack()
426
        distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass()
427
        longPressTimer = CreateObject("roSGNode", "Timer")
428

429
        ' Spatial navigation direction validators (reused across calls)
430
        spatialValidators = {
431
            "left": function(segments as object, refSegmentLeft as object, refSegmentRight as object) as object
432
                right = segments[Rotor.Const.Segment.RIGHT]
2✔
433
                return right.x2 <= refSegmentLeft.x1 ? { isValid: true, segment: right } : { isValid: false }
2✔
434
            end function,
435
            "up": function(segments as object, refSegmentTop as object, refSegmentBottom as object) as object
436
                bottom = segments[Rotor.Const.Segment.BOTTOM]
2✔
437
                return bottom.y2 <= refSegmentTop.y1 ? { isValid: true, segment: bottom } : { isValid: false }
2✔
438
            end function,
439
            "right": function(segments as object, refSegmentLeft as object, refSegmentRight as object) as object
440
                left = segments[Rotor.Const.Segment.LEFT]
2✔
441
                return left.x1 >= refSegmentRight.x2 ? { isValid: true, segment: left } : { isValid: false }
2✔
442
            end function,
443
            "down": function(segments as object, refSegmentTop as object, refSegmentBottom as object) as object
444
                top = segments[Rotor.Const.Segment.TOP]
2✔
445
                return top.y1 >= refSegmentBottom.y2 ? { isValid: true, segment: top } : { isValid: false }
2✔
446
            end function
447
        }
448

449
        ' ---------------------------------------------------------------------
450
        ' init - Initializes the plugin instance
451
        '
452
        ' Sets up internal state and helpers.
453
        '
454
        sub init ()
455
            m.widgetTree = m.frameworkInstance.builder.widgetTree ' Reference to the main widget tree
2✔
456
            m.longPressTimer.addField("pluginKey", "string", false)
2✔
457
            m.longPressTimer.setFields({
2✔
458
                "pluginKey": m.pluginKey,
459
                duration: m.longPressDuration
460
            })
461
            ' Observe timer fire event to handle long press callback
462
            m.longPressTimer.observeFieldScoped("fire", "Rotor_FocusPluginHelper_longPressObserverCallback", ["pluginKey"])
2✔
463
        end sub
464

465
        '
466
        ' storeGlobalFocusHID - Stores the globally focused widget's HID and ID
467
        '
468
        ' @param {string} HID - The Hierarchical ID of the focused widget
469
        ' @param {string} id - The regular ID of the focused widget
470
        '
471
        sub storeGlobalFocusHID(HID as string, id as string)
472
            ' Store focus reference within the plugin
473
            m.globalFocusHID = HID
2✔
474
            m.globalFocusId = id
2✔
475
        end sub
476

477
        '
478
        ' getFocusedWidget - Gets the widget instance that currently holds global focus
479
        '
480
        ' @returns {object} The focused widget object, or invalid if none
481
        '
482
        function getFocusedWidget() as object
483
            return m.getFocusedItem()?.widget
2✔
484
        end function
485

486
        '
487
        ' getFocusedItem - Gets the FocusItem instance corresponding to the globally focused widget
488
        '
489
        ' @returns {object} The FocusItem instance, or invalid if none
490
        '
491
        function getFocusedItem() as object
492
            return m.focusItemStack.get(m.globalFocusHID)
2✔
493
        end function
494

495
        '
496
        ' setFocusConfig - Configures focus properties (FocusItem and/or Group) for a widget
497
        '
498
        ' @param {object} widget - The widget to configure
499
        ' @param {object} pluginConfig - The focus configuration object from the widget's spec
500
        '
501
        sub setFocusConfig(widget as object, pluginConfig as object)
502

503
            if pluginConfig = invalid then return ' No config provided
4✔
504
            HID = widget.HID
2✔
505
            id = widget.id
2✔
506

507
            ' Make a copy to avoid modifying the original config
508
            config = Rotor.Utils.deepCopy(pluginConfig)
2✔
509

510
            ' Ensure essential identifiers are in the config
511
            config.id = id
2✔
512
            config.HID = widget.HID
2✔
513

514
            ' Handle group configuration if present
515
            if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY)
6✔
516
                ' Handle focus item configuration if applicable
517
                m.setupFocusItem(HID, config, widget)
2✔
518
            else
519
                ' Handle group configuration
6✔
520
                m.setupGroup(HID, config, widget)
2✔
521
            end if
522
        end sub
523

524
        '
525
        ' setupGroup - Creates and registers a new Focus Group based on configuration
526
        '
527
        ' @param {string} HID - The Hierarchical ID of the widget acting as the group root
528
        ' @param {object} config - The full focus configuration for the widget
529
        ' @param {object} widget - The widget instance itself
530
        '
531
        sub setupGroup(HID as string, config as object, widget as object)
532
            ' Copy essential info to the group-specific config
533
            config.id = config.id
2✔
534
            config.HID = config.HID
2✔
535
            config.widget = widget
2✔
536
            ' Create and configure the Group instance
537
            newGroup = new Rotor.FocusPluginHelper.GroupClass(config)
2✔
538
            newGroup.focusItemsRef = m.focusItemStack ' Provide reference to focus items
2✔
539
            newGroup.groupsRef = m.groupStack ' Provide reference to other groups
2✔
540
            m.groupStack.set(config.HID, newGroup) ' Register the new group
2✔
541
        end sub
542

543
        '
544
        ' setupFocusItem - Creates and registers a new Focus Item based on configuration
545
        '
546
        ' @param {string} HID - The Hierarchical ID of the focusItem widget
547
        ' @param {object} config - The full focus configuration for the widget
548
        ' @param {object} widget - The widget instance itself
549
        '
550
        sub setupFocusItem(HID as string, config as object, widget as object)
551
            config.widget = widget ' Ensure widget reference is in the config
2✔
552

553
            ' Create and register the FocusItem instance
554
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
2✔
555
            m.focusItemStack.set(HID, newFocusItem)
2✔
556

557
            ' Restore focus state if this widget had global focus
558
            if m.globalFocusHID = HID
4✔
559
                newFocusItem.isFocused = true
2✔
560
            end if
561
        end sub
562

563
        '
564
        ' findAncestorGroups - Finds all ancestor groups for a given widget HID
565
        '
566
        ' @param {string} HID - The Hierarchical ID of the widget
567
        ' @returns {object} An roArray of ancestor group HIDs, sorted with the immediate parent first (descending HID length)
568
        '
569
        function findAncestorGroups(HID as string) as object
570
            allGroups = m.groupStack.getAll() ' Get all registered groups
2✔
571
            ancestorGroups = []
2✔
572
            ' Iterate through all groups to find ancestors
573
            for each groupHID in allGroups
2✔
574
                if Rotor.Utils.isAncestorHID(groupHID, HID)
6✔
575
                    ancestorGroups.push(groupHID)
2✔
576
                end if
577
            end for
578
            ' Sort by HID length descending (parent first)
579
            ancestorGroups.Sort("r")
2✔
580

581
            ' Note:
582
            ' - Parent group is at index 0
583
            ' - If HID is a focusItem, its direct parent group is included
584
            ' - If HID is a group, the group itself is NOT included
585
            return ancestorGroups
2✔
586
        end function
587

588
        '
589
        ' removeFocusConfig - Removes focus configuration (Group and/or FocusItem) for a widget
590
        '
591
        ' @param {string} HID - The Hierarchical ID of the widget whose config should be removed
592
        '
593
        sub removeFocusConfig(HID as string)
594
            ' Remove associated group, if it exists
595
            if m.groupStack.has(HID)
4✔
596
                m.groupStack.remove(HID)
2✔
597
            end if
598
            ' Remove associated focus item, if it exists
599
            if m.focusItemStack.has(HID)
6✔
600
                m.focusItemStack.remove(HID)
2✔
601
            end if
602
        end sub
603

604
        '
605
        ' setFocus - Sets or removes focus from a specific widget or group
606
        '
607
        ' Handles focus state changes, callbacks, and native focus interaction.
608
        '
609
        ' @param {dynamic} ref - The target: HID (string) of a FocusItem or Group, or Node ID (string) of a Group
610
        ' @param {boolean} isFocused - True to set focus, false to remove focus (default: true)
611
        ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node (default: false)
612
        ' @returns {boolean} True if the focus state was successfully changed, false otherwise
613
        '
614
        function setFocus(ref as dynamic, isFocused = true as boolean, enableNativeFocus = false as boolean) as boolean
615

616
            ' Resolve reference (HID or ID) to a focusItem item.
617
            focusItem = invalid ' Initialize target focus item
2✔
618

619
            ' Exit if reference is empty or invalid.
620
            if ref = invalid or ref = "" then return false
4✔
621

622
            if m.focusItemStack.has(ref)
6✔
623
                ' Case 1: ref is a valid focusItem HID.
624
                focusItem = m.focusItemStack.get(ref)
2✔
625
            else
626
                ' Case 2: ref might be a focusItem node ID.
6✔
627
                focusItem = m.focusItemStack.getByNodeId(ref)
2✔
628

629
                if focusItem = invalid
6✔
630
                    ' Case 3: ref might be a group HID or group node ID.
631
                    ' Try finding group by HID first, then by Node ID.
632
                    group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
2✔
633
                    if group <> invalid
6✔
634
                        ' If group found, find its default/entry focus item recursively.
635
                        ' Use lastNavigationDirection so enableSpatialEnter groups can pick the right entry point
636
                        HID = m.capturingFocus_recursively(group.HID, m.lastNavigationDirection)
2✔
637
                        focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
2✔
638
                        ' else: ref is not a known FocusItem HID or Group identifier
639
                    end if
640
                end if
641
            end if
642

643
            ' Handle case where the target focus item could not be found or resolved.
644
            if focusItem = invalid
4✔
645
                focused = m.focusItemStack.get(m.globalFocusHID) ' Check current focus
2✔
646
                #if debug
8✔
647
                    ' Log warnings if focus target is not found
648
                    if focused = invalid
4✔
649
                        print `[PLUGIN][FOCUS][WARNING] Requested focus target ref: "${ref}" was not found or resolved to a valid FocusItem.`
×
650
                        if m.globalFocusHID = ""
×
651
                            ' If global focus is also lost, indicate potential issue.
652
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue likely. No current focus set. Ensure valid initial focus.`
×
653
                        else
×
654
                            print `[PLUGIN][FOCUS][WARNING] Current focus HID: "${m.globalFocusHID}". Ensure target "${ref}" is registered and reachable.`
×
655
                        end if
656
                    else
6✔
657
                        print `[PLUGIN][FOCUS][WARNING] Could not find focus target ref: "${ref}". Current focus remains on HID: "${m.globalFocusHID}", id"${m.globalFocusId}"".`
2✔
658
                    end if
659
                #end if
660
                return false ' Indicate focus change failed
2✔
661
            end if
662

663
            ' Found a valid focusItem to target
664
            HID = focusItem.HID
2✔
665

666
            ' Exit if already focused/blurred as requested (no change needed).
667
            if HID = m.globalFocusHID and isFocused = true then return false
4✔
668
            ' Note: Handling blur when already blurred might be needed depending on desired logic, currently allows blurring focused item.
669

670
            ' Cannot focus an invisible item.
671
            if focusItem.node.visible = false and isFocused = true then return false
4✔
672

673
            ' Determine if native focus should be enabled (request or item default)
674
            enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
2✔
675

676
            ' Prevent focusing a disabled item.
677
            preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
2✔
678
            if preventFocusOnDisabled
4✔
679
                return false ' Indicate focus change failed
×
680
            end if
681

682
            ' Prepare ancestor groups for notification (from highest ancestor to closest parent)
683
            focusChainGroups = m.findAncestorGroups(focusItem.HID) ' Groups containing the new focus
2✔
684

685
            lastFocusChainingGroups = []
2✔
686

687
            ' Handle blurring the previously focused item
688
            if m.globalFocusHID <> "" ' If something was focused before
4✔
689
                lastFocused = m.focusItemStack.get(m.globalFocusHID)
1✔
690
                if lastFocused <> invalid ' Check if the last focused widget hasn't been destroyed
6✔
691
                    ' Record the last focused item within its parent group for potential future use (e.g., returning focus)
692
                    lastFocusChainingGroups = m.findAncestorGroups(m.globalFocusHID)
2✔
693
                    for i = 0 to lastFocusChainingGroups.Count() - 1
2✔
694
                        ancestorGroupHID = lastFocusChainingGroups[i]
2✔
695
                        ancestorGroup = m.groupStack.get(ancestorGroupHID)
2✔
696
                        if ancestorGroup <> invalid
6✔
697
                            ' For immediate parent (index 0): set if enableLastFocusId is true (default)
698
                            ' For other ancestors: set if enableDeepLastFocusId is enabled
699
                            shouldSetLastFocusId = (i = 0 and ancestorGroup.enableLastFocusId) or (i > 0 and ancestorGroup.enableDeepLastFocusId)
2✔
700
                            if shouldSetLastFocusId
5✔
701
                                ancestorGroup.setLastFocusedHID(m.globalFocusHID)
2✔
702
                            end if
703
                        end if
704
                    end for
705
                end if
706
            end if
707

708
            ' Prepare notification list: all affected groups (unique)
709
            allAffectedGroups = []
2✔
710
            for each groupHID in focusChainGroups
2✔
711
                allAffectedGroups.unshift(groupHID) ' Add in reverse order (highest ancestor first)
2✔
712
            end for
713
            for i = 0 to lastFocusChainingGroups.Count() - 1
2✔
714
                groupHID = lastFocusChainingGroups[i]
2✔
715

716
                ' Add to allAffectedGroups if not present
717
                if -1 = Rotor.Utils.findInArray(allAffectedGroups, groupHID)
4✔
718
                    allAffectedGroups.unshift(groupHID)
2✔
719
                end if
720
            end for
721

722
            ' Notify all ancestor groups BEFORE applying focus (from highest ancestor to closest parent)
723
            m.notifyFocusAtAncestorGroups(focusItem.HID, allAffectedGroups)
2✔
724

725
            ' Blur the previously focused item (after notification)
726
            if m.globalFocusHID <> "" and lastFocused <> invalid
4✔
727
                lastFocused.applyFocus(false, enableNativeFocus)
2✔
728
            end if
729

730
            ' Apply focus state (focused/blurred) to the target item.
731
            focusItem.applyFocus(isFocused, enableNativeFocus)
2✔
732

733
            ' Update the globally tracked focused item.
734
            m.storeGlobalFocusHID(isFocused ? HID : "", isFocused ? focusItem.id : "")
2✔
735

736
            ' Ensure SceneGraph root has focus if native focus wasn't explicitly enabled on the item.
737
            if enableNativeFocus = false
6✔
738
                globalScope = GetGlobalAA()
2✔
739
                if globalScope.top.isInFocusChain() = false
4✔
740
                    globalScope.top.setFocus(true)
2✔
741
                end if
742
            end if
743

744
            return true
2✔
745

746
        end function
747

748
        '
749
        ' notifyFocusAtAncestorGroups - Applies the correct focus state (in focus chain or not) to a list of group HIDs
750
        '
751
        ' @param {string} HID - The HID of the item that ultimately received/lost focus
752
        ' @param {object} groupHIDs - An roArray of group HIDs to notify
753
        '
754
        sub notifyFocusAtAncestorGroups(HID as string, groupHIDs = [] as object)
755

756
            ' Notify all ancestor groups
757
            if groupHIDs.Count() > 0
6✔
758
                for each groupHID in groupHIDs
2✔
759

760
                    group = m.groupStack.get(groupHID)
2✔
761
                    isInFocusChain = Rotor.Utils.isAncestorHID(groupHID, HID)
2✔
762
                    group.applyFocus(isInFocusChain)
2✔
763

764
                end for
765
            end if
766
        end sub
767

768
        sub notifyLongPressAtAncestorGroups(isLongPress as boolean, key as string, HID as string, groupHIDs = [] as object)
769
            ' Notify all ancestor groups
770
            if groupHIDs.Count() > 0
6✔
771
                for each groupHID in groupHIDs
2✔
772
                    group = m.groupStack.get(groupHID)
2✔
773
                    handled = group.callLongPressHandler(isLongPress, key)
2✔
774
                    if handled then exit for
4✔
775
                end for
776
            end if
777
        end sub
778

779
        function delegateKeyPress(key as string) as boolean
780
            focused = m.getFocusedItem()
2✔
781
            if focused = invalid then return false
4✔
782

783
            ' Bubble through ancestor groups (focused item already checked by caller)
784
            focusChainGroups = m.findAncestorGroups(focused.HID)
2✔
785
            for each groupHID in focusChainGroups
2✔
786
                group = m.groupStack.get(groupHID)
2✔
787
                handled = group.callKeyPressHandler(key)
2✔
788
                if handled then return true
4✔
789
            end for
790

791
            return false
2✔
792
        end function
793

794
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
795
            focused = m.getFocusedItem()
2✔
796
            if focused = invalid then return
4✔
797

798
            handled = focused.callLongPressHandler(isLongPress, key)
2✔
799
            if handled then return
4✔
800

801
            focusChainGroups = m.findAncestorGroups(focused.HID)
2✔
802
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
2✔
803
        end sub
804

805
        function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object, bypassFocusedCheck = false as boolean) as string
806
            ' Skip if focused item doesn't participate in spatial nav (unless bypassed for cross-group nav)
807
            if not bypassFocusedCheck and focused.enableSpatialNavigation = false then return ""
3✔
808
            if direction = Rotor.Const.Direction.BACK then return ""
4✔
809

810
            ' Remove current focused item from candidates
811
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
2✔
812
            if index >= 0 then focusItemsHIDlist.delete(index)
3✔
813

814
            ' Find closest focusable item in direction
815
            focusedMetrics = focused.refreshBounding()
2✔
816
            segments = m.collectSegments(focusedMetrics, direction, focusItemsHIDlist, focused.HID)
2✔
817
            if segments.Count() > 0
6✔
818
                return m.findClosestSegment(segments, focusedMetrics.middlePoint)
2✔
819
            end if
820

821
            return ""
×
822
        end function
823

824
        function findClosestSegment(segments as object, middlePoint as object) as string
825
            distances = []
2✔
826

827
            ' Calculate distance from middle point to each segment
828
            for each HID in segments
2✔
829
                segment = segments[HID]
2✔
830
                distance = m.distanceCalculator.distToSegment(middlePoint, {
2✔
831
                    x: segment.x1,
832
                    y: segment.y1
833
                }, {
834
                    x: segment.x2,
835
                    y: segment.y2
836
                })
837

838
                distances.push({
2✔
839
                    HID: HID,
840
                    distance: distance
841
                })
842
            end for
843

844
            ' Find segment with minimum distance
845
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
2✔
846
                return a < b
847
            end function)
848

849
            return minDistItem.HID
2✔
850
        end function
851

852

853
        ' Waterfall of fallback's of groups (linked together with defaultFocusId)
854
        function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
855
            ' Resolve identifier to a group
856
            group = m.groupStack.get(identifier)
2✔
857
            if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
2✔
858
            if group = invalid then return ""
4✔
859

860
            newHID = ""
2✔
861

862
            ' enableSpatialEnter: use spatialNavigation to find closest member
863
            if group.isSpatialEnterEnabledForDirection(direction)
4✔
864
                if direction <> "" and m.globalFocusHID <> ""
6✔
865
                    focused = m.focusItemStack.get(m.globalFocusHID)
2✔
866
                    if focused <> invalid
6✔
867
                        ' Get group members and use spatial navigation to find closest
868
                        members = group.getGroupMembersHIDs()
2✔
869
                        if members.count() > 0
6✔
870
                            newHID = m.spatialNavigation(focused, direction, members, true)
2✔
871
                            ' If spatial nav found a group (not a focus item), recursively resolve it
872
                            if newHID <> "" and m.groupStack.has(newHID)
4✔
873
                                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
×
874
                            end if
875
                        end if
876
                    end if
877
                end if
878
            end if
879

880
            ' Fallback to getFallbackIdentifier if spatial enter didn't find anything
881
            if newHID = ""
6✔
882
                newHID = group.getFallbackIdentifier(m.globalFocusHID, direction)
2✔
883
            end if
884

885
            ' Check if we found a FocusItem
886
            if m.focusItemStack.has(newHID)
6✔
887
                ' noop — direct focusItem resolved
888
            else if newHID <> ""
6✔
889
                ' Try to find as group first, then deep search
890
                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
2✔
891

892
                ' If still not found, perform deep search in all descendants
893
                if newHID = ""
4✔
894
                    newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
2✔
895
                end if
896
            end if
897

898
            ' Prevent capturing by fallback in the same group where original focus was
899
            ' Skip this guard for enableSpatialEnter groups (spatial enter explicitly targets a sibling group's member)
900
            if not group.isSpatialEnterEnabledForDirection(direction) and newHID <> "" and m.globalFocusHID <> ""
4✔
901
                currentAncestors = m.findAncestorGroups(m.globalFocusHID)
2✔
902
                newAncestors = m.findAncestorGroups(newHID)
2✔
903
                if currentAncestors.Count() > 0 and newAncestors.Count() > 0
6✔
904
                    if currentAncestors[0] = newAncestors[0] and newHID <> m.globalFocusHID then newHID = ""
4✔
905
                end if
906
            end if
907

908
            return newHID
2✔
909
        end function
910

911
        '
912
        ' deepSearchFocusItemByNodeId - Deep search for a FocusItem or Group by nodeId within a group hierarchy
913
        '
914
        ' @param {string} groupHID - The HID of the group to search within
915
        ' @param {string} nodeId - The node ID to search for
916
        ' @returns {string} The HID of the found FocusItem or Group, or empty string if not found
917
        '
918
        function deepSearchFocusItemByNodeId(groupHID as string, nodeId as string) as string
919
            if nodeId = "" then return ""
2✔
920

921
            ' Get all descendants of this group (both FocusItems and nested Groups)
922
            allFocusItems = m.focusItemStack.getAll()
2✔
923
            allGroups = m.groupStack.getAll()
2✔
924

925
            ' First, search in direct and nested FocusItems
926
            for each focusItemHID in allFocusItems
2✔
927
                if Rotor.Utils.isDescendantHID(focusItemHID, groupHID)
6✔
928
                    focusItem = m.focusItemStack.get(focusItemHID)
2✔
929
                    if focusItem <> invalid and focusItem.id = nodeId
4✔
930
                        return focusItemHID
2✔
931
                    end if
932
                end if
933
            end for
934

935
            ' Second, search in nested Groups (and if found, apply fallback logic on that group)
936
            for each nestedGroupHID in allGroups
2✔
937
                if Rotor.Utils.isDescendantHID(nestedGroupHID, groupHID) and nestedGroupHID <> groupHID
6✔
938
                    nestedGroup = m.groupStack.get(nestedGroupHID)
2✔
939
                    if nestedGroup <> invalid and nestedGroup.id = nodeId
4✔
940
                        ' Found a matching group - now apply fallback logic on it
941
                        fallbackHID = nestedGroup.getFallbackIdentifier()
×
942
                        if m.focusItemStack.has(fallbackHID)
×
943
                            return fallbackHID
×
944
                        else if fallbackHID <> ""
×
945
                            ' Recursively resolve the fallback
946
                            return m.capturingFocus_recursively(fallbackHID, "", nestedGroupHID)
×
947
                        end if
948
                    end if
949
                end if
950
            end for
951

952
            return ""
2✔
953
        end function
954

955
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
956
            newHID = ""
2✔
957

958
            ' Build ancestor chain (current group + all ancestors)
959
            ancestorGroups = m.findAncestorGroups(groupHID)
2✔
960
            ancestorGroups.unshift(groupHID)
2✔
961
            ancestorGroupsCount = ancestorGroups.Count()
2✔
962
            ancestorIndex = 0
2✔
963

964
            ' Get currently focused item for spatial navigation
965
            focused = m.focusItemStack.get(m.globalFocusHID)
2✔
966

967
            ' Bubble up through ancestor groups until we find a target or reach the top
968
            while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
2✔
969
                ' Get next ancestor group
970
                groupHID = ancestorGroups[ancestorIndex]
2✔
971
                group = m.groupStack.get(groupHID)
2✔
972

973
                ' Check group's direction configuration
974
                nodeId = group.getStaticNodeIdInDirection(direction)
2✔
975

976
                if Rotor.Utils.isBoolean(nodeId)
4✔
977
                    ' Boolean means focus is explicitly handled
978
                    if nodeId = true
6✔
979
                        newHID = true ' Block navigation (exit loop)
2✔
980
                    else
×
981
                        newHID = "" ' Continue bubbling
×
982
                    end if
983
                else
984
                    ' String nodeId - try to resolve target
6✔
985
                    if nodeId <> ""
6✔
986
                        otherGroup = m.groupStack.getByNodeId(nodeId)
2✔
987
                        if otherGroup <> invalid
6✔
988
                            newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
2✔
989
                        end if
990
                    else
991
                        ' No explicit direction - try spatial navigation at this group level
992
                        ' This allows navigation between sibling child groups with enableSpatialNavigation
993
                        ' Skip during long press to allow bubbling up to parent carousel for continuous scrolling
994
                        ' Skip for "back" direction - spatial navigation doesn't apply
6✔
995
                        if focused <> invalid and m.isLongPress = false and direction <> Rotor.Const.Direction.BACK
6✔
996
                            groupMembers = group.getGroupMembersHIDs()
2✔
997
                            ' Check if this group has any child groups with spatial nav enabled
998
                            hasSpatialNavGroups = false
2✔
999
                            for each memberHID in groupMembers
2✔
1000
                                if m.groupStack.has(memberHID)
4✔
1001
                                    memberGroup = m.groupStack.get(memberHID)
×
1002
                                    if memberGroup.enableSpatialNavigation = true
×
1003
                                        hasSpatialNavGroups = true
×
1004
                                        exit for
1005
                                    end if
1006
                                end if
1007
                            end for
1008
                            ' If there are spatial-nav-enabled child groups, try spatial navigation
1009
                            ' Use bypassFocusedCheck=true since we're navigating between groups, not within
1010
                            if hasSpatialNavGroups
4✔
1011
                                newHID = m.spatialNavigation(focused, direction, groupMembers, true)
×
1012
                                ' If spatial nav found a group, use capturing focus to enter it
1013
                                if newHID <> "" and m.groupStack.has(newHID)
×
1014
                                    newHID = m.capturingFocus_recursively(newHID, direction)
×
1015
                                end if
1016
                            end if
1017
                        end if
1018
                    end if
1019
                end if
1020

1021
                ancestorIndex++
2✔
1022
            end while
1023

1024
            return newHID
2✔
1025
        end function
1026

1027
        ' * KEY EVENT HANDLER
1028
        function onKeyEventHandler(key as string, press as boolean) as object
1029
            ' Check long-press
1030
            if m.enableLongPressFeature = true
4✔
1031
                m.checkLongPressState(key, press)
2✔
1032
            end if
1033
            ' Prevent any navigation if it is disabled
1034
            #if debug
8✔
1035
            #end if
1036
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
4✔
1037
            ' Execute action according to key press
1038
            return m.executeNavigationAction(key, press)
2✔
1039
        end function
1040

1041
        function executeNavigationAction(key as string, press as boolean) as object
1042

1043
            if true = press
6✔
1044

1045
                ' keyPressHandler: local (no bubbling), fires for all keys before navigation
1046
                focusedItem = m.focusItemStack.get(m.globalFocusHID)
2✔
1047
                if focusedItem <> invalid and focusedItem.keyPressHandler <> invalid
5✔
1048
                    if true = Rotor.Utils.callbackScoped(focusedItem.keyPressHandler, focusedItem.widget, key)
5✔
1049
                        return m.parseOnKeyEventResult(key, true, false)
2✔
1050
                    end if
1051
                end if
1052

1053
                if -1 < Rotor.Utils.findInArray([
4✔
1054
                        Rotor.Const.Direction.UP,
1055
                        Rotor.Const.Direction.RIGHT,
1056
                        Rotor.Const.Direction.DOWN,
1057
                        Rotor.Const.Direction.LEFT,
1058
                        Rotor.Const.Direction.BACK
1059
                    ], key)
1060

1061
                    newHID = ""
2✔
1062
                    direction = key
2✔
1063
                    m.lastNavigationDirection = direction
2✔
1064

1065
                    ' (1) Pick up current focused item
1066

1067
                    focused = m.focusItemStack.get(m.globalFocusHID)
2✔
1068

1069
                    if focused = invalid
4✔
1070
                        #if debug
×
1071
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue detected. Last known focus id:\"${m.globalFocusHID}\". Please ensure valid focus.`
×
1072
                        #end if
1073
                        return m.parseOnKeyEventResult(key, false, false)
×
1074
                    end if
1075

1076

1077
                    ancestorGroups = m.findAncestorGroups(focused.HID)
2✔
1078
                    ancestorGroupsCount = ancestorGroups.Count()
2✔
1079

1080
                    if ancestorGroupsCount = 0
4✔
1081
                        allFocusItems = m.focusItemStack.getAll()
2✔
1082
                        possibleFocusItems = allFocusItems.keys()
2✔
1083
                        parentGroupHID = ""
2✔
1084
                    else
6✔
1085
                        parentGroupHID = ancestorGroups[0]
2✔
1086
                        group = m.groupStack.get(parentGroupHID)
2✔
1087
                        possibleFocusItems = group.getGroupMembersHIDs()
2✔
1088
                    end if
1089

1090
                    ' (2) Try static direction, defined on the focusItem, among possible focusItems
1091
                    nodeId = focused.getStaticNodeIdInDirection(direction) ' Note that this is a nodeId
2✔
1092

1093
                    if Rotor.Utils.isBoolean(nodeId) and nodeId = true
4✔
1094
                        ' It means that focus is handled, and no need further action by plugin.
1095
                        return m.parseOnKeyEventResult(key, true, false)
×
1096
                    end if
1097

1098
                    if nodeId <> ""
4✔
1099
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
1100
                    end if
1101

1102
                    if newHID = ""
6✔
1103
                        ' (3) Try spatial navigation in direction, among possible focusItems
1104
                        ' all = m.focusItemStack.getAll()
1105
                        ' allKeys = all.Keys()
1106
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
2✔
1107
                    end if
1108

1109
                    ' (4) Check if found group. FocusItem can not point out of group.
1110
                    if newHID = "" and ancestorGroupsCount > 0 ' (5/2) If this focused has parent group, lets try bubbling focus on ancestors (groups)
5✔
1111
                        newHID = m.bubblingFocus(parentGroupHID, direction)
2✔
1112
                        if Rotor.Utils.isBoolean(newHID)
4✔
1113
                            if newHID = true
6✔
1114
                                ' It means that focus is handled, and no need further action by plugin.
1115
                                return m.parseOnKeyEventResult(key, true, false)
2✔
1116
                            else
×
1117
                                newHID = ""
×
1118
                            end if
1119
                        end if
1120
                    end if
1121

1122
                    handled = m.setFocus(newHID)
2✔
1123
                    return m.parseOnKeyEventResult(key, handled, false)
2✔
1124

1125
                else if key = "OK"
4✔
1126

1127
                    return m.parseOnKeyEventResult(key, true, true)
2✔
1128

1129
                end if
1130

1131
                ' Unhandled key (not direction, not OK) → bubble through ancestor groups
1132
                if m.delegateKeyPress(key) then return m.parseOnKeyEventResult(key, true, false)
4✔
1133
            end if
1134

1135
            return m.parseOnKeyEventResult(key, false, false)
2✔
1136

1137
        end function
1138

1139
        function parseOnKeyEventResult(key as string, handled as boolean, isSelected as boolean) as object
1140
            result = {
2✔
1141
                handled: handled,
1142
                key: key
1143
            }
1144
            if m.globalFocusHID <> "" and handled = true
5✔
1145
                focusItem = m.focusItemStack.get(m.globalFocusHID)
2✔
1146
                widget = m.widgetTree.get(focusItem.HID)
2✔
1147
                ' viewModelState = Rotor.Utils.deepCopy(widget.viewModelState)
1148
                result.widget = widget
2✔
1149
                if isSelected
4✔
1150
                    result.isSelected = isSelected
2✔
1151
                    focusItem.callOnSelectFnOnWidget()
2✔
1152
                end if
1153
            end if
1154
            return result
2✔
1155
        end function
1156

1157
        sub checkLongPressState(key as string, press as boolean)
1158
            m.longPressTimer.control = "stop"
2✔
1159
            if press = true
4✔
1160
                if m.isLongPress = false
6✔
1161
                    m.longPressKey = key
2✔
1162
                    m.longPressTimer.control = "start"
2✔
1163
                end if
1164
            else
6✔
1165
                wasLongPress = m.isLongPress = true
2✔
1166
                lastKey = m.longPressKey
2✔
1167
                m.isLongPress = false
2✔
1168
                m.longPressKey = ""
2✔
1169
                if wasLongPress
4✔
1170
                    m.delegateLongPressChanged(false, lastKey)
×
1171
                end if
1172
            end if
1173
        end sub
1174

1175
        function proceedLongPress() as object
1176
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(m.longPressKey, false, false)
2✔
1177
            return m.executeNavigationAction(m.longPressKey, true)
2✔
1178
        end function
1179

1180
        ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
1181
        ' @param focusedMetrics - The metrics object from focused.refreshBounding()
1182
        ' @param focusedHID - The HID of the focused item (to exclude from candidates)
1183
        function collectSegments(focusedMetrics as object, direction as string, focusItemsHIDlist as object, focusedHID as string) as object
1184
            refSegments = focusedMetrics.segments
2✔
1185
            refSegmentTop = refSegments[Rotor.Const.Segment.TOP]
2✔
1186
            refSegmentRight = refSegments[Rotor.Const.Segment.RIGHT]
2✔
1187
            refSegmentLeft = refSegments[Rotor.Const.Segment.LEFT]
2✔
1188
            refSegmentBottom = refSegments[Rotor.Const.Segment.BOTTOM]
2✔
1189

1190
            segments = {}
2✔
1191
            validator = m.spatialValidators[direction]
2✔
1192
            for each HID in focusItemsHIDlist
2✔
1193
                if HID <> focusedHID
6✔
1194
                    ' Try to get as FocusItem first, then as Group
1195
                    candidate = m.focusItemStack.get(HID)
2✔
1196
                    isGroup = false
2✔
1197
                    if candidate = invalid
4✔
1198
                        candidate = m.groupStack.get(HID)
×
1199
                        isGroup = true
×
1200
                    end if
1201
                    if candidate = invalid then continue for
4✔
1202

1203
                    ' Skip disabled items - they should not be candidates for spatial navigation
1204
                    if not isGroup and candidate.isEnabled = false then continue for
4✔
1205

1206
                    candidateMetrics = candidate.refreshBounding()
2✔
1207
                    ' Pass appropriate reference segments based on direction
1208
                    if direction = "left" or direction = "right"
6✔
1209
                        result = validator(candidateMetrics.segments, refSegmentLeft, refSegmentRight)
2✔
1210
                    else ' up or down
6✔
1211
                        result = validator(candidateMetrics.segments, refSegmentTop, refSegmentBottom)
2✔
1212
                    end if
1213
                    if result.isValid
6✔
1214
                        segments[HID] = result.segment
2✔
1215
                    end if
1216
                end if
1217
            end for
1218

1219
            return segments
2✔
1220
        end function
1221

1222
        sub destroy()
1223
            ' Remove all groups
1224
            for each HID in m.groupStack.getAll()
2✔
1225
                m.groupStack.remove(HID)
2✔
1226
            end for
1227
            ' Remove all focus items
1228
            for each HID in m.focusItemStack.getAll()
2✔
1229
                m.focusItemStack.remove(HID)
2✔
1230
            end for
1231
            m.longPressTimer.unobserveFieldScoped("fire")
2✔
1232
            m.longPressTimer = invalid
2✔
1233
            m.widgetTree = invalid
2✔
1234
        end sub
1235

1236
    end class
1237

1238
    namespace FocusPluginHelper
1239

1240
        class BaseEntryStack extends Rotor.BaseStack
1241

1242
            function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
1243
                if ancestorHID <> "0"
6✔
1244
                    filteredStack = {}
2✔
1245
                    for each HID in m.stack
2✔
1246
                        if Rotor.Utils.isDescendantHID(HID, ancestorHID)
6✔
1247
                            filteredStack[HID] = m.get(HID)
2✔
1248
                        end if
1249
                    end for
1250
                else
6✔
1251
                    filteredStack = m.stack
2✔
1252
                end if
1253
                HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
2✔
1254
                return HID <> "" ? m.get(HID) : invalid
2✔
1255
            end function
1256

1257
            override sub remove(HID as string)
1258
                item = m.get(HID)
2✔
1259
                item.destroy()
2✔
1260
                super.remove(HID)
2✔
1261
            end sub
1262

1263
        end class
1264

1265
        class GroupStack extends BaseEntryStack
1266

1267
            function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1268
                foundHID = ""
×
1269
                for each HID in possibleGroups
×
1270
                    group = m.get(HID)
×
1271
                    if group.id = nodeId
×
1272
                        foundHID = group.HID
×
1273
                        exit for
1274
                    end if
1275
                end for
1276
                return foundHID
×
1277
            end function
1278

1279
        end class
1280

1281

1282
        class FocusItemStack extends BaseEntryStack
1283

1284
            function convertNodeIdToHID(nodeId as string, possibleFocusItems as object) as string
1285
                foundHID = ""
×
1286
                for each HID in possibleFocusItems
×
1287
                    focusItem = m.get(HID)
×
1288
                    if focusItem?.id = nodeId
×
1289
                        foundHID = focusItem.HID
×
1290
                        exit for
1291
                    end if
1292
                end for
1293
                return foundHID
×
1294
            end function
1295

1296
            function hasEnabled(HID as string) as boolean
1297
                if m.has(HID)
6✔
1298
                    focusItem = m.get(HID)
2✔
1299
                    return focusItem.isEnabled
2✔
1300
                else
6✔
1301
                    return false
2✔
1302
                end if
1303
            end function
1304

1305
        end class
1306

1307
        class BaseFocusConfig
1308

1309
            staticDirection as object
1310

1311
            sub new (config as object)
1312

1313
                m.HID = config.HID
2✔
1314
                m.id = config.id
2✔
1315

1316
                m.widget = config.widget
2✔
1317
                m.node = m.widget.node
2✔
1318
                m.isFocused = config.isFocused ?? false
2✔
1319

1320
                m.isEnabled = config.isEnabled ?? true
2✔
1321
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? false
2✔
1322
                m.staticDirection = {}
2✔
1323
                m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
2✔
1324
                m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
2✔
1325
                m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
2✔
1326
                m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
2✔
1327
                m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
2✔
1328

1329
                m.onFocusChanged = config.onFocusChanged
2✔
1330
                m.longPressHandler = config.longPressHandler
2✔
1331
                m.keyPressHandler = config.keyPressHandler
2✔
1332
                m.onFocus = config.onFocus
2✔
1333
                m.onBlur = config.onBlur
2✔
1334

1335
                Rotor.Utils.setCustomFields(m.node, { "isFocused": false }, true, true)
2✔
1336

1337
                if m.widget.isViewModel = true and not m.widget.viewModelState.DoesExist("isFocused")
4✔
1338
                    m.widget.viewModelState.isFocused = false
×
1339
                end if
1340

1341
            end sub
1342

1343

1344
            HID as string
1345
            id as string
1346
            idByKeys as object
1347
            isEnabled as boolean
1348
            isFocused as boolean
1349
            enableSpatialNavigation as boolean
1350
            onFocusChanged as dynamic
1351
            onFocus as dynamic
1352
            onBlur as dynamic
1353
            longPressHandler as dynamic
1354
            keyPressHandler as dynamic
1355
            node as object
1356
            widget as object
1357

1358
            protected metrics = {
1359
                segments: {}
1360
            }
1361

1362
            function refreshBounding() as object
1363
                b = m.node.sceneBoundingRect()
×
1364
                rotation = m.node.rotation
×
1365

1366
                if rotation = 0
×
1367
                    if b.y = 0 and b.x = 0
×
1368
                        t = m.node.translation
×
1369
                        b.x += t[0]
×
1370
                        b.y += t[1]
×
1371
                    end if
1372

1373
                    m.metrics.append(b)
×
1374
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
×
1375
                        x1: b.x, y1: b.y,
1376
                        x2: b.x, y2: b.y + b.height
1377
                    }
1378
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
×
1379
                        x1: b.x, y1: b.y,
1380
                        x2: b.x + b.width, y2: b.y
1381
                    }
1382
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
×
1383
                        x1: b.x + b.width, y1: b.y,
1384
                        x2: b.x + b.width, y2: b.y + b.height
1385
                    }
1386
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
×
1387
                        x1: b.x, y1: b.y + b.height,
1388
                        x2: b.x + b.width, y2: b.y + b.height
1389
                    }
1390
                    m.metrics.middlePoint = {
×
1391
                        x: b.x + b.width / 2,
1392
                        y: b.y + b.height / 2
1393
                    }
1394
                end if
1395

1396
                return m.metrics
×
1397
            end function
1398

1399
            function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1400
                direction = m.staticDirection[direction]
2✔
1401
                if Rotor.Utils.isFunction(direction)
4✔
1402
                    return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
×
1403
                else
6✔
1404
                    return direction ?? ""
2✔
1405
                end if
1406
            end function
1407

1408
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1409
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
2✔
1410
                if true = isFocused
6✔
1411
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
2✔
1412
                else
6✔
1413
                    Rotor.Utils.callbackScoped(m.onBlur, m.widget)
2✔
1414
                end if
1415
            end sub
1416

1417
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1418
                if Rotor.Utils.isFunction(m.longPressHandler)
4✔
1419
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1420
                else
6✔
1421
                    return false
2✔
1422
                end if
1423
            end function
1424

1425
            function callKeyPressHandler(key as string) as boolean
1426
                if Rotor.Utils.isFunction(m.keyPressHandler)
4✔
UNCOV
1427
                    return Rotor.Utils.callbackScoped(m.keyPressHandler, m.widget, key)
1✔
1428
                else
6✔
1429
                    return false
2✔
1430
                end if
1431
            end function
1432

1433
            sub destroy()
1434
                m.widget = invalid
2✔
1435
                m.node = invalid
2✔
1436
                m.onFocusChanged = invalid
2✔
1437
                m.onFocus = invalid
2✔
1438
                m.onBlur = invalid
2✔
1439
                m.longPressHandler = invalid
2✔
1440
                m.keyPressHandler = invalid
2✔
1441
            end sub
1442

1443
        end class
1444

1445
        class GroupClass extends BaseFocusConfig
1446
            ' Note: Spatial navigation is supported within group, there is no spatial navigation between groups
1447
            ' If you want to focus out to another group, you need to config a direction prop.
1448
            ' You can set a groupId or any focusItem widgetId.
1449
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1450
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1451

1452
            sub new (config as object)
1453
                super(config)
2✔
1454
                m.defaultFocusId = config.defaultFocusId ?? ""
2✔
1455
                m.lastFocusedHID = config.lastFocusedHID ?? ""
2✔
1456
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
2✔
1457
                m.enableLastFocusId = config.enableLastFocusId ?? true
2✔
1458
                m.enableDeepLastFocusId = config.enableDeepLastFocusId ?? false
1459
            end sub
1460

1461
            defaultFocusId as string
1462
            lastFocusedHID as string
1463
            enableSpatialEnter as dynamic ' boolean | { up?: boolean, down?: boolean, left?: boolean, right?: boolean }
1464
            enableLastFocusId as boolean
1465
            enableDeepLastFocusId as boolean
1466

1467
            '
1468
            ' isSpatialEnterEnabledForDirection - Checks if spatial enter is enabled for a specific direction
1469
            '
1470
            ' @param {string} direction - The direction to check (up, down, left, right)
1471
            ' @returns {boolean} True if spatial enter is enabled for the direction
1472
            '
1473
            function isSpatialEnterEnabledForDirection(direction as string) as boolean
1474
                if Rotor.Utils.isBoolean(m.enableSpatialEnter)
6✔
1475
                    return m.enableSpatialEnter
2✔
1476
                else if Rotor.Utils.isAssociativeArray(m.enableSpatialEnter)
6✔
1477
                    return m.enableSpatialEnter[direction] = true
2✔
1478
                end if
1479
                return false
×
1480
            end function
1481
            focusItemsRef as object
1482
            groupsRef as object
1483
            isFocusItem = false
1484
            isGroup = true
1485

1486
            sub setLastFocusedHID(lastFocusedHID as string)
1487
                m.lastFocusedHID = lastFocusedHID
2✔
1488
            end sub
1489

1490
            function getGroupMembersHIDs()
1491
                ' Collect all focusItems that are descendants of this group
1492
                ' Exclude items that belong to nested sub-groups
1493
                ' Also include direct child groups with enableSpatialNavigation: true
1494
                focusItems = m.focusItemsRef.getAll()
2✔
1495
                groups = m.groupsRef.getAll()
2✔
1496
                HIDlen = Len(m.HID)
2✔
1497
                collection = []
2✔
1498
                groupsKeys = groups.keys()
2✔
1499
                groupsCount = groups.Count()
2✔
1500

1501
                ' Collect focusItems (existing logic)
1502
                for each focusItemHID in focusItems
2✔
1503
                    ' Check if focusItem is a descendant of this group
1504
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
2✔
1505
                    if isDescendant
4✔
1506
                        ' Check if focusItem belongs to a nested sub-group
1507
                        shouldExclude = false
2✔
1508
                        otherGroupIndex = 0
2✔
1509
                        while shouldExclude = false and otherGroupIndex < groupsCount
2✔
1510
                            otherGroupHID = groupsKeys[otherGroupIndex]
2✔
1511
                            otherGroupHIDlen = Len(otherGroupHID)
2✔
1512
                            ' Exclude if belongs to deeper nested group
1513
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
2✔
1514
                            otherGroupIndex++
2✔
1515
                        end while
1516

1517
                        if not shouldExclude then collection.push(focusItemHID)
2✔
1518
                    end if
1519
                end for
1520

1521
                ' Collect direct child groups with enableSpatialNavigation: true
1522
                for i = 0 to groupsCount - 1
2✔
1523
                    childGroupHID = groupsKeys[i]
2✔
1524
                    childGroupHIDlen = Len(childGroupHID)
2✔
1525

1526
                    ' Check if it's a descendant of this group (but not this group itself)
1527
                    if childGroupHIDlen > HIDlen and Left(childGroupHID, HIDlen) = m.HID
4✔
1528
                        childGroup = groups[childGroupHID]
2✔
1529

1530
                        ' Only include if enableSpatialNavigation is true
1531
                        if childGroup.enableSpatialNavigation = true
4✔
1532
                            ' Check if it's a DIRECT child (no intermediate groups)
1533
                            isDirectChild = true
×
1534
                            for j = 0 to groupsCount - 1
×
1535
                                intermediateHID = groupsKeys[j]
×
1536
                                intermediateLen = Len(intermediateHID)
×
1537
                                ' Check if there's a group between this group and the child
1538
                                if intermediateLen > HIDlen and intermediateLen < childGroupHIDlen
×
1539
                                    if Left(childGroupHID, intermediateLen) = intermediateHID
×
1540
                                        isDirectChild = false
×
1541
                                        exit for
1542
                                    end if
1543
                                end if
1544
                            end for
1545

1546
                            if isDirectChild then collection.push(childGroupHID)
×
1547
                        end if
1548
                    end if
1549
                end for
1550

1551
                return collection
2✔
1552
            end function
1553

1554
            '
1555
            ' getFallbackNodeId - Returns the nodeId to use for fallback (defaultFocusId or lastFocusedHID)
1556
            '
1557
            ' @returns {string} The nodeId to use for fallback, or empty string if none
1558
            '
1559
            function getFallbackNodeId() as string
1560
                if m.lastFocusedHID <> ""
4✔
1561
                    ' Note: lastFocusedHID is already a HID, not a nodeId, so we need to get the nodeId
1562
                    lastFocusedItem = m.focusItemsRef.get(m.lastFocusedHID)
×
1563
                    if lastFocusedItem <> invalid
×
1564
                        return lastFocusedItem.id
×
1565
                    end if
1566
                end if
1567

1568
                if Rotor.Utils.isFunction(m.defaultFocusId)
4✔
1569
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1570
                else
6✔
1571
                    return m.defaultFocusId
2✔
1572
                end if
1573
            end function
1574

1575
            function getFallbackIdentifier(globalFocusHID = "" as string, direction = "" as string) as string
1576
                HID = ""
2✔
1577
                ' enableSpatialEnter is handled by capturingFocus_recursively using spatialNavigation
1578
                ' Here we only handle lastFocusedHID and defaultFocusId fallbacks
1579

1580
                ' Use lastFocusedHID if available AND still exists (check both focusItems and groups)
1581
                if not m.isSpatialEnterEnabledForDirection(direction) and m.lastFocusedHID <> ""
4✔
1582
                    if m.focusItemsRef.has(m.lastFocusedHID) or m.groupsRef.has(m.lastFocusedHID)
6✔
1583
                        return m.lastFocusedHID
2✔
1584
                    end if
1585
                end if
1586

1587
                ' Default: use defaultFocusId expression
1588
                if Rotor.Utils.isFunction(m.defaultFocusId)
4✔
1589
                    defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
2✔
1590
                else
6✔
1591
                    defaultFocusId = m.defaultFocusId
2✔
1592
                end if
1593

1594
                focusItemsHIDlist = m.getGroupMembersHIDs()
2✔
1595

1596
                if defaultFocusId <> ""
6✔
1597
                    if focusItemsHIDlist.Count() > 0
6✔
1598
                        ' Try find valid HID in focusItems by node id
1599
                        focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
2✔
1600
                        if focusItemHID <> ""
6✔
1601
                            return focusItemHID
2✔
1602
                        end if
1603
                    end if
1604
                    ' If not found as focusItem, return defaultFocusId string
1605
                    ' so capturingFocus_recursively can try to resolve it as a group
1606
                    return defaultFocusId
2✔
1607
                end if
1608

1609
                ' Last resort: pick the first available member of the group
1610
                if focusItemsHIDlist.Count() > 0
6✔
1611
                    return focusItemsHIDlist[0]
2✔
1612
                end if
1613

1614
                return HID
×
1615
            end function
1616

1617
            function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1618
                for each itemHID in focusItemsHIDlist
2✔
1619
                    focusItem = m.focusItemsRef.get(itemHID)
2✔
1620
                    if focusItem <> invalid and focusItem.id = nodeId
6✔
1621
                        return focusItem.HID
2✔
1622
                    end if
1623
                end for
1624
                return ""
×
1625
            end function
1626

1627
            sub applyFocus(isFocused as boolean)
1628
                if m.isFocused = isFocused then return
4✔
1629

1630
                m.isFocused = isFocused
2✔
1631
                m.node.setField("isFocused", isFocused)
2✔
1632
                if m.widget.isViewModel = true then m.widget.viewModelState.isFocused = isFocused
4✔
1633
                m.callOnFocusedFnOnWidget(isFocused)
2✔
1634
            end sub
1635

1636
            override sub destroy()
1637
                super.destroy()
2✔
1638
                m.focusItemsRef = invalid
2✔
1639
                m.groupsRef = invalid
2✔
1640
            end sub
1641

1642

1643

1644
        end class
1645

1646
        class FocusItemClass extends BaseFocusConfig
1647

1648
            sub new (config as object)
1649
                super(config)
2✔
1650

1651
                m.onSelect = config.onSelect ?? config.ok ?? ""
2✔
1652
                m.enableNativeFocus = config.enableNativeFocus ?? false
2✔
1653
            end sub
1654

1655
            ' You can set a groupId or any focusItem widgetId.
1656
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1657
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1658

1659
            ' key as string
1660
            isFocusItem = true
1661
            isGroup = false
1662
            enableNativeFocus as boolean
1663
            onSelect as dynamic
1664

1665
            private bounding as object
1666

1667

1668
            override function refreshBounding() as object
1669
                b = m.node.sceneBoundingRect()
2✔
1670
                rotation = m.node.rotation
2✔
1671

1672
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1673
                ' That is why we can use translation without knowing the value of inheritParentTransform
1674
                ' If bounding x or y are not zero, then bounding will include the node's translation
1675
                if rotation = 0
6✔
1676
                    if b.y = 0 and b.x = 0
4✔
1677
                        t = m.node.translation
2✔
1678
                        b.x += t[0]
2✔
1679
                        b.y += t[1]
2✔
1680
                    end if
1681

1682
                    m.metrics.append(b)
2✔
1683
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
2✔
1684
                        x1: b.x, y1: b.y,
1685
                        x2: b.x, y2: b.y + b.height
1686
                    }
1687
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
2✔
1688
                        x1: b.x, y1: b.y,
1689
                        x2: b.x + b.width, y2: b.y
1690
                    }
1691
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
2✔
1692
                        x1: b.x + b.width, y1: b.y,
1693
                        x2: b.x + b.width, y2: b.y + b.height
1694
                    }
1695
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
2✔
1696
                        x1: b.x, y1: b.y + b.height,
1697
                        x2: b.x + b.width, y2: b.y + b.height
1698
                    }
1699
                    m.metrics.middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
2✔
1700
                else
×
1701
                    scaleRotateCenter = m.node.scaleRotateCenter
×
1702
                    dims = m.node.localBoundingRect() ' We need this to get proper (rotated value of rotated x and y)
×
1703
                    if b.y = 0 and b.x = 0
×
1704
                        t = m.node.translation
×
1705
                        b.x += t[0]
×
1706
                        b.y += t[1]
×
1707
                    end if
1708
                    b.width = dims.width
×
1709
                    b.height = dims.height
×
1710
                    m.metrics.append(b)
×
1711

1712
                    ' Calculate rotated segments
1713
                    segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
×
1714
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1715
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
×
1716

1717
                    segmentTOP = { x1: b.x, y1: b.y, x2: b.x + b.width, y2: b.y }
×
1718
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentTOP.x1, segmentTOP.y1, segmentTOP.x2, segmentTOP.y2, rotation, scaleRotateCenter)
×
1719
                    m.metrics.segments[Rotor.Const.Segment.TOP] = rotatedSegment
×
1720

1721
                    segmentRIGHT = { x1: b.x + b.width, y1: b.y, x2: b.x + b.width, y2: b.y + b.height }
×
1722
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentRIGHT.x1, segmentRIGHT.y1, segmentRIGHT.x2, segmentRIGHT.y2, rotation, scaleRotateCenter)
×
1723
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = rotatedSegment
×
1724

1725
                    segmentBOTTOM = { x1: b.x, y1: b.y + b.height, x2: b.x + b.width, y2: b.y + b.height }
×
1726
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentBOTTOM.x1, segmentBOTTOM.y1, segmentBOTTOM.x2, segmentBOTTOM.y2, rotation, scaleRotateCenter)
×
1727
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = rotatedSegment
×
1728

1729
                    ' Calculate rotated middle point
1730
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1731
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1732
                    m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
×
1733

1734
                end if
1735

1736
                return m.metrics
2✔
1737
            end function
1738

1739
            override sub destroy()
1740
                m.onSelect = invalid
2✔
1741
                m.metrics.segments.Clear()
2✔
1742
                super.destroy()
2✔
1743
            end sub
1744

1745
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1746
                if m.isFocused = isFocused then return
4✔
1747

1748
                m.isFocused = isFocused
2✔
1749
                m.node.setField("isFocused", isFocused)
2✔
1750
                if m.widget.isViewModel = true then m.widget.viewModelState.isFocused = isFocused
4✔
1751

1752
                if enableNativeFocus or m.enableNativeFocus
4✔
1753
                    m.node.setFocus(isFocused)
2✔
1754
                end if
1755

1756
                m.callOnFocusedFnOnWidget(isFocused)
2✔
1757

1758
            end sub
1759

1760
            sub callOnSelectFnOnWidget()
1761
                Rotor.Utils.callbackScoped(m.onSelect, m.widget)
2✔
1762
            end sub
1763

1764
        end class
1765

1766
        class ClosestSegmentToPointCalculatorClass
1767

1768
            ' Translated from js; source: https://stackoverflow.com/a/6853926/16164491 (author:Joshua)
1769
            function pDistance(x, y, x1, y1, x2, y2)
1770

1771
                A = x - x1
2✔
1772
                B = y - y1
2✔
1773
                C = x2 - x1
2✔
1774
                D = y2 - y1
2✔
1775

1776
                dot = A * C + B * D
2✔
1777
                len_sq = C * C + D * D
2✔
1778
                param = -1
2✔
1779
                if len_sq <> 0
6✔
1780
                    param = dot / len_sq
2✔
1781
                end if
1782

1783
                xx = 0
2✔
1784
                yy = 0
2✔
1785

1786
                if param < 0
4✔
1787
                    xx = x1
2✔
1788
                    yy = y1
2✔
1789
                else if param > 1
4✔
1790
                    xx = x2
2✔
1791
                    yy = y2
2✔
1792
                else
6✔
1793
                    xx = x1 + param * C
2✔
1794
                    yy = y1 + param * D
2✔
1795
                end if
1796

1797
                dx = x - xx
2✔
1798
                dy = y - yy
2✔
1799
                return dx * dx + dy * dy
2✔
1800
            end function
1801

1802
            function distToSegment(p as object, s1 as object, s2 as object)
1803
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
2✔
1804
            end function
1805

1806
        end class
1807

1808
    end namespace
1809

1810
    namespace FocusPluginHelper
1811

1812
        sub longPressObserverCallback(msg)
1813
            extraInfo = msg.GetInfo()
2✔
1814

1815
            pluginKey = extraInfo["pluginKey"]
2✔
1816

1817
            globalScope = GetGlobalAA()
2✔
1818
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
2✔
1819
            plugin = frameworkInstance.plugins[pluginKey]
2✔
1820
            plugin.isLongPress = true
2✔
1821
            ' plugin.longPressStartHID = plugin.globalFocusHID
1822
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
2✔
1823

1824
        end sub
1825

1826
    end namespace
1827

1828
end namespace
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