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

mobalazs / rotor-framework / 28649741742

03 Jul 2026 08:54AM UTC coverage: 90.883% (-0.08%) from 90.958%
28649741742

push

github

mobalazs
chore: bump version to 0.9.1

2213 of 2435 relevant lines covered (90.88%)

1.26 hits per line

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

80.83
/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
1✔
189
                isFocusGroup = widget.DoesExist(GROUP_FOCUS_PLUGIN_KEY) and widget.focusGroup <> invalid
1✔
190

191
                if isFocusItem and isFocusGroup
2✔
192
                    throw "[FOCUS_PLUGIN][ERROR] Widget '" + widget.id + "' (HID: " + widget.HID + ") cannot have both 'focus' and 'focusGroup' configurations!"
193
                end if
194

195
                config = widget[isFocusItem ? PRIMARY_FOCUS_PLUGIN_KEY : GROUP_FOCUS_PLUGIN_KEY]
1✔
196
                scope.setFocusConfig(widget, config)
1✔
197
            end sub,
198

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

213
                ' Determine whether this widget is a focus item or focus group
214
                targetKey = PRIMARY_FOCUS_PLUGIN_KEY
1✔
215
                if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY) and widget[PRIMARY_FOCUS_PLUGIN_KEY] <> invalid
3✔
216
                    targetKey = PRIMARY_FOCUS_PLUGIN_KEY
1✔
217
                else
3✔
218
                    targetKey = GROUP_FOCUS_PLUGIN_KEY
1✔
219
                end if
220

221
                ' Ensure target config exists
222
                if not Rotor.Utils.isAssociativeArray(widget[targetKey])
2✔
223
                    widget[targetKey] = {}
×
224
                end if
225

226
                ' Merge new config into existing widget config (or replace if non-AA)
227
                if Rotor.Utils.isAssociativeArray(newValue)
3✔
228
                    Rotor.Utils.deepExtendAA(widget[targetKey], newValue)
1✔
229
                else
×
230
                    widget[targetKey] = newValue
×
231
                end if
232

233
                scope.setFocusConfig(widget, widget[targetKey])
1✔
234
            end sub,
235

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

253
        ' Widget methods - Injected into widgets managed by this plugin
254
        widgetMethods = {
255

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

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

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

285
                if Rotor.Utils.isString(command)
2✔
286
                    return plugin.setFocus(command, true, enableNativeFocus)
1✔
287
                else
3✔
288
                    return plugin.setFocus(HID, command, enableNativeFocus)
1✔
289
                end if
290
            end function,
291

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

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

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

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

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

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

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

362
                ' Check if this widget is a group
363
                group = plugin.groupStack.get(m.HID)
1✔
364
                if group <> invalid
3✔
365
                    group.setLastFocusedHID(resolvedHID)
1✔
366
                    return
1✔
367
                end if
368

369
                ' This is a focus item - find parent group
370
                ancestorGroups = plugin.findAncestorGroups(m.HID)
1✔
371
                if ancestorGroups.count() > 0
3✔
372
                    parentGroupHID = ancestorGroups[0]
1✔
373
                    parentGroup = plugin.groupStack.get(parentGroupHID)
1✔
374
                    if parentGroup <> invalid
3✔
375
                        parentGroup.setLastFocusedHID(resolvedHID)
1✔
376
                    end if
377
                end if
378
            end sub,
379

380
            ' ---------------------------------------------------------------------
381
            ' isFocused - Checks if this widget currently has focus
382
            '
383
            ' For a FocusItem: returns whether this item is the focused element
384
            ' For a Group: returns whether any descendant within this group has focus (is in focus chain)
385
            ' For widgets without focus config: returns false
386
            '
387
            ' @returns {boolean} True if focused (or in focus chain for groups), false otherwise
388
            '
389
            isFocused: function() as boolean
390
                plugin = m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY]
1✔
391
                focusItem = plugin.focusItemStack.get(m.HID)
1✔
392
                if focusItem <> invalid
3✔
393
                    return focusItem.isFocused
1✔
394
                end if
395
                group = plugin.groupStack.get(m.HID)
1✔
396
                if group <> invalid
3✔
397
                    return group.isFocused
1✔
398
                end if
399
                return false
×
400
            end function
401

402
        }
403

404
        ' Configuration
405
        longPressDuration = 0.4
406
        enableLongPressFeature = true
407
        enableFocusNavigation = true
408

409
        ' State tracking
410
        globalFocusHID = ""
411
        globalFocusId = ""
412
        lastNavigationDirection = ""
413
        isLongPress = false
414
        longPressKey = ""
415

416
        ' References
417
        widgetTree as object
418
        frameworkInstance as Rotor.Framework
419

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

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

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

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

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

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

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

500
            if pluginConfig = invalid then return ' No config provided
2✔
501
            HID = widget.HID
1✔
502
            id = widget.id
1✔
503

504
            ' Make a copy to avoid modifying the original config
505
            config = Rotor.Utils.deepCopy(pluginConfig)
1✔
506

507
            ' Ensure essential identifiers are in the config
508
            config.id = id
1✔
509
            config.HID = widget.HID
1✔
510

511
            ' Handle group configuration if present
512
            if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY)
3✔
513
                ' Handle focus item configuration if applicable
514
                m.setupFocusItem(HID, config, widget)
1✔
515
            else
516
                ' Handle group configuration
3✔
517
                m.setupGroup(HID, config, widget)
1✔
518
            end if
519
        end sub
520

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

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

550
            ' Create and register the FocusItem instance
551
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
1✔
552
            m.focusItemStack.set(HID, newFocusItem)
1✔
553

554
            ' Restore focus state if this widget had global focus
555
            if m.globalFocusHID = HID
2✔
556
                newFocusItem.isFocused = true
1✔
557
            end if
558
        end sub
559

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

578
            ' Note:
579
            ' - Parent group is at index 0
580
            ' - If HID is a focusItem, its direct parent group is included
581
            ' - If HID is a group, the group itself is NOT included
582
            return ancestorGroups
1✔
583
        end function
584

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

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

613
            ' Resolve reference (HID or ID) to a focusItem item.
614
            focusItem = invalid ' Initialize target focus item
1✔
615

616
            ' Exit if reference is empty or invalid.
617
            if ref = invalid or ref = "" then return false
2✔
618

619
            if m.focusItemStack.has(ref)
3✔
620
                ' Case 1: ref is a valid focusItem HID.
621
                focusItem = m.focusItemStack.get(ref)
1✔
622
            else
623
                ' Case 2: ref might be a focusItem node ID.
3✔
624
                focusItem = m.focusItemStack.getByNodeId(ref)
1✔
625

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

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

660
            ' Found a valid focusItem to target
661
            HID = focusItem.HID
1✔
662

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

667
            ' Cannot focus an invisible item.
668
            if focusItem.node.visible = false and isFocused = true then return false
2✔
669

670
            ' Determine if native focus should be enabled (request or item default)
671
            enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
1✔
672

673
            ' Prevent focusing a disabled item.
674
            preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
1✔
675
            if preventFocusOnDisabled
2✔
676
                return false ' Indicate focus change failed
×
677
            end if
678

679
            ' Prepare ancestor groups for notification (from highest ancestor to closest parent)
680
            focusChainGroups = m.findAncestorGroups(focusItem.HID) ' Groups containing the new focus
1✔
681

682
            lastFocusChainingGroups = []
1✔
683

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

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

713
                ' Add to allAffectedGroups if not present
714
                if -1 = Rotor.Utils.findInArray(allAffectedGroups, groupHID)
2✔
715
                    allAffectedGroups.unshift(groupHID)
1✔
716
                end if
717
            end for
718

719
            ' Notify all ancestor groups BEFORE applying focus (from highest ancestor to closest parent)
720
            m.notifyFocusAtAncestorGroups(focusItem.HID, allAffectedGroups)
1✔
721

722
            ' Blur the previously focused item (after notification)
723
            if m.globalFocusHID <> "" and lastFocused <> invalid
2✔
724
                lastFocused.applyFocus(false, enableNativeFocus)
1✔
725
            end if
726

727
            ' Apply focus state (focused/blurred) to the target item.
728
            focusItem.applyFocus(isFocused, enableNativeFocus)
1✔
729

730
            ' Update the globally tracked focused item.
731
            m.storeGlobalFocusHID(isFocused ? HID : "", isFocused ? focusItem.id : "")
1✔
732

733
            ' Ensure SceneGraph root has focus if native focus wasn't explicitly enabled on the item.
734
            if enableNativeFocus = false
3✔
735
                globalScope = GetGlobalAA()
1✔
736
                if globalScope.top.isInFocusChain() = false
2✔
737
                    globalScope.top.setFocus(true)
1✔
738
                end if
739
            end if
740

741
            return true
1✔
742

743
        end function
744

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

753
            ' Notify all ancestor groups
754
            if groupHIDs.Count() > 0
3✔
755
                for each groupHID in groupHIDs
1✔
756

757
                    group = m.groupStack.get(groupHID)
1✔
758
                    isInFocusChain = Rotor.Utils.isAncestorHID(groupHID, HID)
1✔
759
                    group.applyFocus(isInFocusChain)
1✔
760

761
                end for
762
            end if
763
        end sub
764

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

776
        function delegateKeyPress(key as string) as boolean
777
            focused = m.getFocusedItem()
1✔
778
            if focused = invalid then return false
2✔
779

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

788
            return false
1✔
789
        end function
790

791
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
792
            focused = m.getFocusedItem()
×
793
            if focused = invalid then return
×
794

795
            handled = focused.callLongPressHandler(isLongPress, key)
×
796
            if handled then return
×
797

798
            focusChainGroups = m.findAncestorGroups(focused.HID)
×
799
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
×
800
        end sub
801

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

807
            ' Remove current focused item from candidates
808
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
1✔
809
            if index >= 0 then focusItemsHIDlist.delete(index)
1✔
810

811
            ' Find closest focusable item in direction
812
            focusedMetrics = focused.refreshBounding()
1✔
813
            segments = m.collectSegments(focusedMetrics, direction, focusItemsHIDlist, focused.HID)
1✔
814
            if segments.Count() > 0
3✔
815
                return m.findClosestSegment(segments, focusedMetrics.middlePoint)
1✔
816
            end if
817

818
            return ""
×
819
        end function
820

821
        function findClosestSegment(segments as object, middlePoint as object) as string
822
            distances = []
1✔
823

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

835
                distances.push({
1✔
836
                    HID: HID,
837
                    distance: distance
838
                })
839
            end for
840

841
            ' Find segment with minimum distance
842
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
1✔
843
                return a < b
844
            end function)
845

846
            return minDistItem.HID
1✔
847
        end function
848

849

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

857
            newHID = ""
1✔
858

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

877
            ' Fallback to getFallbackIdentifier if spatial enter didn't find anything
878
            if newHID = ""
3✔
879
                newHID = group.getFallbackIdentifier(m.globalFocusHID, direction)
1✔
880
            end if
881

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

889
                ' If still not found, perform deep search in all descendants
890
                if newHID = ""
2✔
891
                    newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
1✔
892
                end if
893
            end if
894

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

905
            return newHID
1✔
906
        end function
907

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

918
            ' Get all descendants of this group (both FocusItems and nested Groups)
919
            allFocusItems = m.focusItemStack.getAll()
1✔
920
            allGroups = m.groupStack.getAll()
1✔
921

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

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

949
            return ""
1✔
950
        end function
951

952
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
953
            newHID = ""
1✔
954

955
            ' Build ancestor chain (current group + all ancestors)
956
            ancestorGroups = m.findAncestorGroups(groupHID)
1✔
957
            ancestorGroups.unshift(groupHID)
1✔
958
            ancestorGroupsCount = ancestorGroups.Count()
1✔
959
            ancestorIndex = 0
1✔
960

961
            ' Get currently focused item for spatial navigation
962
            focused = m.focusItemStack.get(m.globalFocusHID)
1✔
963

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

970
                ' Check group's direction configuration
971
                nodeId = group.getStaticNodeIdInDirection(direction)
1✔
972

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

1018
                ancestorIndex++
1✔
1019
            end while
1020

1021
            return newHID
1✔
1022
        end function
1023

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

1038
        function executeNavigationAction(key as string, press as boolean) as object
1039

1040
            if true = press
3✔
1041

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

1050
                if -1 < Rotor.Utils.findInArray([
2✔
1051
                        Rotor.Const.Direction.UP,
1052
                        Rotor.Const.Direction.RIGHT,
1053
                        Rotor.Const.Direction.DOWN,
1054
                        Rotor.Const.Direction.LEFT,
1055
                        Rotor.Const.Direction.BACK
1056
                    ], key)
1057

1058
                    newHID = ""
1✔
1059
                    direction = key
1✔
1060
                    m.lastNavigationDirection = direction
1✔
1061

1062
                    ' (1) Pick up current focused item
1063

1064
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
1065

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

1073

1074
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
1075
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
1076

1077
                    if ancestorGroupsCount = 0
2✔
1078
                        allFocusItems = m.focusItemStack.getAll()
1✔
1079
                        possibleFocusItems = allFocusItems.keys()
1✔
1080
                        parentGroupHID = ""
1✔
1081
                    else
3✔
1082
                        parentGroupHID = ancestorGroups[0]
1✔
1083
                        group = m.groupStack.get(parentGroupHID)
1✔
1084
                        possibleFocusItems = group.getGroupMembersHIDs()
1✔
1085
                    end if
1086

1087
                    ' (2) Try static direction, defined on the focusItem, among possible focusItems
1088
                    nodeId = focused.getStaticNodeIdInDirection(direction) ' Note that this is a nodeId
1✔
1089

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

1095
                    if nodeId <> ""
2✔
1096
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
1097
                    end if
1098

1099
                    if newHID = ""
3✔
1100
                        ' (3) Try spatial navigation in direction, among possible focusItems
1101
                        ' all = m.focusItemStack.getAll()
1102
                        ' allKeys = all.Keys()
1103
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
1✔
1104
                    end if
1105

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

1119
                    handled = m.setFocus(newHID)
1✔
1120
                    return m.parseOnKeyEventResult(key, handled, false)
1✔
1121

1122
                else if key = "OK"
2✔
1123

1124
                    return m.parseOnKeyEventResult(key, true, true)
1✔
1125

1126
                end if
1127

1128
                ' Unhandled key (not direction, not OK) → bubble through ancestor groups
1129
                if m.delegateKeyPress(key) then return m.parseOnKeyEventResult(key, true, false)
2✔
1130
            end if
1131

1132
            return m.parseOnKeyEventResult(key, false, false)
1✔
1133

1134
        end function
1135

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

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

1172
        function proceedLongPress() as object
1173
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(m.longPressKey, false, false)
1✔
1174
            return m.executeNavigationAction(m.longPressKey, true)
1✔
1175
        end function
1176

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

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

1200
                    ' Skip disabled items - they should not be candidates for spatial navigation
1201
                    if not isGroup and candidate.isEnabled = false then continue for
2✔
1202

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

1216
            return segments
1✔
1217
        end function
1218

1219
        override sub destroy()
1220
            ' Per-widget focus configs are already torn down via the beforeDestroy
1221
            ' hook (removeFocusConfig) during widget removal. Here we only drop any
1222
            ' remaining stack references and release plugin-level resources -- we do
1223
            ' NOT re-run remove()/entry.destroy(), which would touch already-freed
1224
            ' widget nodes and crash.
1225
            m.groupStack.clear()
1✔
1226
            m.focusItemStack.clear()
1✔
1227
            if m.longPressTimer <> invalid
3✔
1228
                m.longPressTimer.unobserveFieldScoped("fire")
1✔
1229
                m.longPressTimer = invalid
1✔
1230
            end if
1231
            m.widgetTree = invalid
1✔
1232
        end sub
1233

1234
    end class
1235

1236
    namespace FocusPluginHelper
1237

1238
        class BaseEntryStack extends Rotor.BaseStack
1239

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

1255
            override sub remove(HID as string)
1256
                item = m.get(HID)
1✔
1257
                item.destroy()
1✔
1258
                super.remove(HID)
1✔
1259
            end sub
1260

1261
        end class
1262

1263
        class GroupStack extends BaseEntryStack
1264

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

1277
        end class
1278

1279

1280
        class FocusItemStack extends BaseEntryStack
1281

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

1294
            function hasEnabled(HID as string) as boolean
1295
                if m.has(HID)
3✔
1296
                    focusItem = m.get(HID)
1✔
1297
                    return focusItem.isEnabled
1✔
1298
                else
3✔
1299
                    return false
1✔
1300
                end if
1301
            end function
1302

1303
        end class
1304

1305
        class BaseFocusConfig
1306

1307
            staticDirection as object
1308

1309
            sub new (config as object)
1310

1311
                m.HID = config.HID
1✔
1312
                m.id = config.id
1✔
1313

1314
                m.widget = config.widget
1✔
1315
                m.node = m.widget.node
1✔
1316
                m.isFocused = config.isFocused ?? false
1✔
1317

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

1327
                m.onFocusChanged = config.onFocusChanged
1✔
1328
                m.longPressHandler = config.longPressHandler
1✔
1329
                m.keyPressHandler = config.keyPressHandler
1✔
1330
                m.onFocus = config.onFocus
1✔
1331
                m.onBlur = config.onBlur
1✔
1332

1333
                Rotor.Utils.setCustomFields(m.node, { "isFocused": false }, true, true)
1✔
1334

1335
                if m.widget.isViewModel = true and not m.widget.viewModelState.DoesExist("isFocused")
2✔
1336
                    m.widget.viewModelState.isFocused = false
×
1337
                end if
1338

1339
            end sub
1340

1341

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

1356
            protected metrics = {
1357
                segments: {}
1358
            }
1359

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

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

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

1394
                return m.metrics
×
1395
            end function
1396

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

1406
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1407
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1408
                if true = isFocused
3✔
1409
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1410
                else
3✔
1411
                    Rotor.Utils.callbackScoped(m.onBlur, m.widget)
1✔
1412
                end if
1413
            end sub
1414

1415
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1416
                if Rotor.Utils.isFunction(m.longPressHandler)
×
1417
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1418
                else
×
1419
                    return false
×
1420
                end if
1421
            end function
1422

1423
            function callKeyPressHandler(key as string) as boolean
1424
                if Rotor.Utils.isFunction(m.keyPressHandler)
2✔
1425
                    return Rotor.Utils.callbackScoped(m.keyPressHandler, m.widget, key)
1✔
1426
                else
3✔
1427
                    return false
1✔
1428
                end if
1429
            end function
1430

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

1441
        end class
1442

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

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

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

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

1484
            sub setLastFocusedHID(lastFocusedHID as string)
1485
                m.lastFocusedHID = lastFocusedHID
1✔
1486
            end sub
1487

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

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

1515
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1516
                    end if
1517
                end for
1518

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

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

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

1544
                            if isDirectChild then collection.push(childGroupHID)
×
1545
                        end if
1546
                    end if
1547
                end for
1548

1549
                return collection
1✔
1550
            end function
1551

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

1566
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1567
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1568
                else
3✔
1569
                    return m.defaultFocusId
1✔
1570
                end if
1571
            end function
1572

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

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

1585
                ' Default: use defaultFocusId expression
1586
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1587
                    defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
1✔
1588
                else
3✔
1589
                    defaultFocusId = m.defaultFocusId
1✔
1590
                end if
1591

1592
                focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1593

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

1607
                ' Last resort: pick the first available member of the group
1608
                if focusItemsHIDlist.Count() > 0
3✔
1609
                    return focusItemsHIDlist[0]
1✔
1610
                end if
1611

1612
                return HID
×
1613
            end function
1614

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

1625
            sub applyFocus(isFocused as boolean)
1626
                if m.isFocused = isFocused then return
2✔
1627

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

1634
            override sub destroy()
1635
                super.destroy()
1✔
1636
                m.focusItemsRef = invalid
1✔
1637
                m.groupsRef = invalid
1✔
1638
            end sub
1639

1640

1641

1642
        end class
1643

1644
        class FocusItemClass extends BaseFocusConfig
1645

1646
            sub new (config as object)
1647
                super(config)
1✔
1648

1649
                m.onSelect = config.onSelect ?? config.ok ?? ""
1✔
1650
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1651
            end sub
1652

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

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

1663
            private bounding as object
1664

1665

1666
            override function refreshBounding() as object
1667
                b = m.node.sceneBoundingRect()
1✔
1668
                rotation = m.node.rotation
1✔
1669

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

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

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

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

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

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

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

1732
                end if
1733

1734
                return m.metrics
1✔
1735
            end function
1736

1737
            override sub destroy()
1738
                m.onSelect = invalid
1✔
1739
                m.metrics.segments.Clear()
1✔
1740
                super.destroy()
1✔
1741
            end sub
1742

1743
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1744
                if m.isFocused = isFocused then return
2✔
1745

1746
                m.isFocused = isFocused
1✔
1747
                m.node.setField("isFocused", isFocused)
1✔
1748
                if m.widget.isViewModel = true then m.widget.viewModelState.isFocused = isFocused
2✔
1749

1750
                if enableNativeFocus or m.enableNativeFocus
2✔
1751
                    m.node.setFocus(isFocused)
1✔
1752
                end if
1753

1754
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1755

1756
            end sub
1757

1758
            sub callOnSelectFnOnWidget()
1759
                Rotor.Utils.callbackScoped(m.onSelect, m.widget)
1✔
1760
            end sub
1761

1762
        end class
1763

1764
        class ClosestSegmentToPointCalculatorClass
1765

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

1769
                A = x - x1
1✔
1770
                B = y - y1
1✔
1771
                C = x2 - x1
1✔
1772
                D = y2 - y1
1✔
1773

1774
                dot = A * C + B * D
1✔
1775
                len_sq = C * C + D * D
1✔
1776
                param = -1
1✔
1777
                if len_sq <> 0
3✔
1778
                    param = dot / len_sq
1✔
1779
                end if
1780

1781
                xx = 0
1✔
1782
                yy = 0
1✔
1783

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

1795
                dx = x - xx
1✔
1796
                dy = y - yy
1✔
1797
                return dx * dx + dy * dy
1✔
1798
            end function
1799

1800
            function distToSegment(p as object, s1 as object, s2 as object)
1801
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1802
            end function
1803

1804
        end class
1805

1806
    end namespace
1807

1808
    namespace FocusPluginHelper
1809

1810
        sub longPressObserverCallback(msg)
1811
            extraInfo = msg.GetInfo()
×
1812

1813
            pluginKey = extraInfo["pluginKey"]
×
1814

1815
            globalScope = GetGlobalAA()
×
1816
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
×
1817
            plugin = frameworkInstance.plugins[pluginKey]
×
1818
            plugin.isLongPress = true
×
1819
            ' plugin.longPressStartHID = plugin.globalFocusHID
1820
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
×
1821

1822
        end sub
1823

1824
    end namespace
1825

1826
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