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

mobalazs / rotor-framework / 19898939182

03 Dec 2025 03:18PM UTC coverage: 86.131% (-0.05%) from 86.182%
19898939182

push

github

mobalazs
Merge branch 'fix/focus-horiz-overlap-detection'

6 of 6 new or added lines in 1 file covered. (100.0%)

2 existing lines in 2 files now uncovered.

1770 of 2055 relevant lines covered (86.13%)

1.17 hits per line

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

72.64
/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
    '   - ONLY works within a single group
81
    '   - Cannot cross into sibling or parent groups
82
    '   - Searches only possibleFocusItems from group.getGroupMembersHIDs()
83
    '
84
    ' RULE #5: Group Direction Activation
85
    '   Group direction triggers ONLY when:
86
    '   - FocusItem has NO static direction
87
    '   - Spatial navigation found NOTHING
88
    '   - BubblingFocus reaches this group
89
    '
90
    ' RULE #6: Group Direction Values
91
    '   - String (Node ID): Navigate to that group/item (may EXIT group)
92
    '   - true: BLOCKS (stays on current element)
93
    '   - false/undefined: Continue bubbling to next ancestor
94
    '
95
    ' RULE #7: Group Direction Does NOT Block Spatial Navigation
96
    '   Setting group.right = true does NOT prevent spatial navigation
97
    '   INSIDE the group. It only blocks EXITING the group when spatial
98
    '   navigation finds nothing.
99
    '
100
    ' RULE #8: Exiting a Group - 3 Methods
101
    '   Method 1: FocusItem explicit direction
102
    '     focusItem.right = "otherGroupItem" → EXITS immediately
103
    '   Method 2: Group direction (via BubblingFocus)
104
    '     group.right = "otherGroup" → EXITS when spatial nav fails
105
    '   Method 3: Ancestor group direction
106
    '     parentGroup.right = "otherGroup" → EXITS when child groups pass
107
    '
108
    ' RULE #9: Blocking Group Exit
109
    '   To prevent exit: group.left = true, group.right = true
110
    '   Exception: FocusItem explicit directions still work!
111
    '
112
    ' RULE #10: BubblingFocus Flow
113
    '   FocusItem (no direction) → Spatial nav (nothing) → Group.direction?
114
    '     - "nodeId" → CapturingFocus(nodeId) [EXIT]
115
    '     - true → STOP (stay on current)
116
    '     - false/undefined → Continue to parent group
117
    '     - No more ancestors → Stay on current
118
    '
119
    ' RULE #11: CapturingFocus Priority
120
    '   1. group.lastFocusedHID (if exists) [AUTO-SAVED]
121
    '   2. group.defaultFocusId [CONFIGURED]
122
    '   3. Deep search (if defaultFocusId not found immediately)
123
    '
124
    ' RULE #12: DefaultFocusId Targets
125
    '   - FocusItem node ID → Focus goes directly to it
126
    '   - Group node ID → Capturing continues on that group
127
    '   - Non-existent ID → Deep search attempts
128
    '
129
    ' RULE #13: Deep Search Activation
130
    '   Triggers when:
131
    '   - CapturingFocus doesn't find defaultFocusId in immediate children
132
    '   - defaultFocusId is not empty
133
    '   Searches:
134
    '   1. All descendant FocusItems (any depth)
135
    '   2. All nested Groups (any depth, applies their fallback)
136
    '
137
    ' RULE #14: Spatial Enter
138
    '   When enableSpatialEnter = true on a group:
139
    '   - Entering the group uses spatial navigation from the direction
140
    '   - Finds geometrically closest item instead of defaultFocusId
141
    '   - Falls back to defaultFocusId if spatial finds nothing
142
    '
143
    ' RULE #15: Navigation Decision Tree Summary
144
    '   User presses direction key:
145
    '     1. FocusItem.direction exists? → Use it (may EXIT group)
146
    '     2. Spatial nav finds item? → Navigate (STAYS in group)
147
    '     3. BubblingFocus: Group.direction?
148
    '        - "nodeId" → EXIT to that target
149
    '        - true → BLOCK (stay)
150
    '        - undefined → Continue to ancestor
151
    '     4. No more ancestors? → STAY on current item
152
    '
153
    ' COMMON PATTERNS:
154
    '   Sidebar + Content:
155
    '     sidebar: { group: { right: true } }
156
    '     menuItem1: { right: "contentFirst" } [explicit exit]
157
    '
158
    '   Modal Dialog (locked):
159
    '     modal: { group: { left: true, right: true, up: true, down: true } }
160
    '
161
    '   Nested Navigation:
162
    '     innerGroup: { group: { down: undefined } } [no direction]
163
    '     outerGroup: { group: { down: "bottomBar" } } [catches bubbling]
164
    '
165
    ' =====================================================================
166
    class FocusPlugin extends Rotor.BasePlugin
167

168
        ' ---------------------------------------------------------------------
169
        ' new - Constructor for the FocusPlugin
170
        '
171
        ' @param {string} key - The key to identify this plugin instance (default: "focus")
172
        '
173
        sub new(key = "focus" as string)
174
            super(key)
1✔
175
        end sub
176

177
        ' Framework lifecycle hooks
178
        hooks = {
179
            ' ---------------------------------------------------------------------
180
            ' beforeMount - Hook executed before a widget is mounted
181
            '
182
            ' Sets initial focus config.
183
            '
184
            ' @param {object} scope - The plugin scope (this instance)
185
            ' @param {object} widget - The widget being mounted
186
            '
187
            beforeMount: sub(scope as object, widget as object)
188
                scope.setFocusConfig(widget, widget[scope.key])
1✔
189
            end sub,
190

191
            ' ---------------------------------------------------------------------
192
            ' beforeUpdate - Hook executed before a widget is updated
193
            '
194
            ' Removes old config, applies new.
195
            '
196
            ' @param {object} scope - The plugin scope (this instance)
197
            ' @param {object} widget - The widget being updated
198
            ' @param {dynamic} newValue - The new plugin configuration value
199
            ' @param {object} oldValue - The previous plugin configuration value (default: {})
200
            '
201
            beforeUpdate: sub(scope as object, widget as object, newValue, oldValue = {})
202
                ' Remove previous config before applying the update
203
                scope.removeFocusConfig(widget.HID)
×
204

205
                ' Merge new config into existing widget config
206
                Rotor.Utils.deepExtendAA(widget[scope.key], newValue)
×
207
                scope.setFocusConfig(widget, widget[scope.key])
×
208
            end sub,
209

210
            ' ---------------------------------------------------------------------
211
            ' beforeDestroy - Hook executed before a widget is destroyed
212
            '
213
            ' Removes focus config.
214
            '
215
            ' @param {object} scope - The plugin scope (this instance)
216
            ' @param {object} widget - The widget being destroyed
217
            '
218
            beforeDestroy: sub(scope as object, widget as object)
219
                scope.removeFocusConfig(widget.HID)
1✔
220
            end sub
221
        }
222

223
        ' Widget methods - Injected into widgets managed by this plugin
224
        widgetMethods = {
225

226
            ' ---------------------------------------------------------------------
227
            ' enableFocusNavigation - Enables or disables focus navigation globally for this plugin
228
            '
229
            ' @param {boolean} enableFocusNavigation - True to enable, false to disable (default: true)
230
            '
231
            enableFocusNavigation: sub(enableFocusNavigation = true as boolean)
232
                globalScope = GetGlobalAA()
×
233
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
×
234
                globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].enableFocusNavigation = enableFocusNavigation
×
235
            end sub,
236

237
            ' ---------------------------------------------------------------------
238
            ' isFocusNavigationEnabled - Checks if focus navigation is currently enabled globally
239
            '
240
            ' @returns {boolean} True if enabled, false otherwise
241
            '
242
            isFocusNavigationEnabled: function() as boolean
243
                globalScope = GetGlobalAA()
1✔
244
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
1✔
245
                return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].enableFocusNavigation
1✔
246
            end function,
247

248
            ' ---------------------------------------------------------------------
249
            ' setFocus - Sets focus to this widget or another specified widget
250
            '
251
            ' @param {dynamic} isFocused - Boolean to focus/blur current widget, or string ID/HID of widget to focus
252
            ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node
253
            ' @returns {boolean} True if focus state was changed successfully, false otherwise
254
            '
255
            setFocus: function(command = true as dynamic, enableNativeFocus = false as boolean) as boolean
256
                globalScope = GetGlobalAA()
1✔
257
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
1✔
258
                HID = m.HID ' Widget's unique Hierarchical ID (bound viewModelState).
1✔
259

260
                if Rotor.Utils.isString(command)
2✔
261
                    ' If string, focus widget by ID/HID.
262
                    otherId = command
1✔
263
                    return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].setFocus(otherId, true, enableNativeFocus)
1✔
264
                else ' Boolean value provided
265
                    ' If boolean, focus/blur current widget.
3✔
266
                    return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].setFocus(HID, command, enableNativeFocus)
1✔
267
                end if
268
            end function,
269

270
            ' ---------------------------------------------------------------------
271
            ' getFocusedWidget - Retrieves the currently focused widget managed by this plugin
272
            '
273
            ' @returns {object} The widget instance that currently holds focus, or invalid
274
            '
275
            getFocusedWidget: function() as object ' Params isFocused, enableNativeFocus seem unused here
276
                globalScope = GetGlobalAA()
1✔
277
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
1✔
278
                return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].getFocusedWidget()
1✔
279
            end function,
280

281
            ' ---------------------------------------------------------------------
282
            ' proceedLongPress - Manually triggers the navigation action associated with the current long-press key
283
            '
284
            ' @returns {object} The result of the executed navigation action (see parseOnKeyEventResult)
285
            '
286
            proceedLongPress: function() as object ' Params isFocused, enableNativeFocus seem unused here
287
                globalScope = GetGlobalAA()
×
288
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
×
289
                return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].proceedLongPress()
×
290
            end function,
291

292
            ' ---------------------------------------------------------------------
293
            ' isLongPressActive - Checks if a long press action is currently active
294
            '
295
            ' @returns {boolean} True if a long press is active, false otherwise
296
            '
297
            isLongPressActive: function() as boolean ' Params isFocused, enableNativeFocus seem unused here
298
                globalScope = GetGlobalAA()
×
299
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
×
300
                return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].isLongPress
×
301
            end function,
302

303
            ' ---------------------------------------------------------------------
304
            ' triggerKeyPress - Simulate key press
305
            '
306
            ' @param {string} key - Pressed key
307
            ' @returns {object} The widget instance that currently holds focus, or invalid
308
            '
309
            triggerKeyPress: function(key) as object
310
                globalScope = GetGlobalAA()
1✔
311
                pluginKey = m.pluginKey
1✔
312
                return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].onKeyEventHandler(key, true)
1✔
313
            end function
314

315
        }
316

317
        ' Configuration
318
        longPressDuration = 0.4
319
        enableLongPressFeature = true
320
        enableFocusNavigation = true
321

322
        ' State tracking
323
        globalFocusHID = ""
324
        globalFocusId = ""
325
        isLongPress = false
326
        longPressKey = ""
327

328
        ' References
329
        widgetTree as object
330
        frameworkInstance as Rotor.Framework
331

332
        ' Helper objects
333
        focusItemStack = new Rotor.FocusPluginHelper.FocusItemStack()
334
        groupStack = new Rotor.FocusPluginHelper.GroupStack()
335
        distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass()
336
        longPressTimer = CreateObject("roSGNode", "Timer")
337

338
        ' ---------------------------------------------------------------------
339
        ' init - Initializes the plugin instance
340
        '
341
        ' Sets up internal state and helpers.
342
        '
343
        sub init ()
344
            m.widgetTree = m.frameworkInstance.builder.widgetTree ' Reference to the main widget tree
1✔
345
            m.longPressTimer.addField("pluginKey", "string", false)
1✔
346
            m.longPressTimer.setFields({
1✔
347
                "pluginKey": m.key,
348
                duration: m.longPressDuration
349
            })
350
            ' Observe timer fire event to handle long press callback
351
            m.longPressTimer.observeFieldScoped("fire", "Rotor_FocusPluginHelper_longPressObserverCallback", ["pluginKey"])
1✔
352
        end sub
353

354
        '
355
        ' storeGlobalFocusHID - Stores the globally focused widget's HID and ID
356
        '
357
        ' @param {string} HID - The Hierarchical ID of the focused widget
358
        ' @param {string} id - The regular ID of the focused widget
359
        '
360
        sub storeGlobalFocusHID(HID as string, id as string)
361
            ' Store focus reference within the plugin
362
            m.globalFocusHID = HID
1✔
363
            m.globalFocusId = id
1✔
364
        end sub
365

366
        '
367
        ' getFocusedWidget - Gets the widget instance that currently holds global focus
368
        '
369
        ' @returns {object} The focused widget object, or invalid if none
370
        '
371
        function getFocusedWidget() as object
372
            return m.getFocusedItem()?.widget
1✔
373
        end function
374

375
        '
376
        ' getFocusedItem - Gets the FocusItem instance corresponding to the globally focused widget
377
        '
378
        ' @returns {object} The FocusItem instance, or invalid if none
379
        '
380
        function getFocusedItem() as object
381
            return m.focusItemStack.get(m.globalFocusHID)
1✔
382
        end function
383

384
        '
385
        ' setFocusConfig - Configures focus properties (FocusItem and/or Group) for a widget
386
        '
387
        ' @param {object} widget - The widget to configure
388
        ' @param {object} pluginConfig - The focus configuration object from the widget's spec
389
        '
390
        sub setFocusConfig(widget as object, pluginConfig as object)
391

392
            if pluginConfig = invalid then return ' No config provided
2✔
393
            HID = widget.HID
1✔
394
            id = widget.id
1✔
395

396
            ' Make a copy to avoid modifying the original config
397
            config = Rotor.Utils.deepCopy(pluginConfig)
1✔
398
            isGroup = config.doesExist(Rotor.Const.GROUP_CONFIG_KEY)
1✔
399
            ' An item is focusItem if it's explicitly a group with other props, or not a group but has props.
400
            ' isFocusItem = (isGroup = true and config.Count() > 1) or (isGroup = false and config.Count() > 0)
401

402
            ' Ensure essential identifiers are in the config
403
            config.id = id
1✔
404
            config.HID = widget.HID
1✔
405

406
            ' Handle group configuration if present
407
            if isGroup
2✔
408
                m.setupGroup(HID, config, widget)
1✔
409
            else
410
                ' Handle focus item configuration if applicable
411
                ' if isFocusItem
3✔
412
                m.setupFocusItem(HID, config, widget)
1✔
413
            end if
414
        end sub
415

416
        '
417
        ' setupGroup - Creates and registers a new Focus Group based on configuration
418
        '
419
        ' @param {string} HID - The Hierarchical ID of the widget acting as the group root
420
        ' @param {object} config - The full focus configuration for the widget
421
        ' @param {object} widget - The widget instance itself
422
        '
423
        sub setupGroup(HID as string, config as object, widget as object)
424
            groupConfig = config[Rotor.Const.GROUP_CONFIG_KEY]
1✔
425
            ' Copy essential info to the group-specific config
426
            groupConfig.id = config.id
1✔
427
            groupConfig.HID = config.HID
1✔
428
            groupConfig.widget = widget
1✔
429
            ' Create and configure the Group instance
430
            newGroup = new Rotor.FocusPluginHelper.GroupClass(groupConfig)
1✔
431
            newGroup.focusItemsRef = m.focusItemStack ' Provide reference to focus items
1✔
432
            newGroup.groupsRef = m.groupStack ' Provide reference to other groups
1✔
433
            m.groupStack.set(config.HID, newGroup) ' Register the new group
1✔
434
        end sub
435

436
        '
437
        ' setupFocusItem - Creates and registers a new Focus Item based on configuration
438
        '
439
        ' @param {string} HID - The Hierarchical ID of the focusItem widget
440
        ' @param {object} config - The full focus configuration for the widget
441
        ' @param {object} widget - The widget instance itself
442
        '
443
        sub setupFocusItem(HID as string, config as object, widget as object)
444
            config.widget = widget ' Ensure widget reference is in the config
1✔
445

446
            ' Create and register the FocusItem instance
447
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
1✔
448
            m.focusItemStack.set(HID, newFocusItem)
1✔
449
        end sub
450

451
        '
452
        ' findAncestorGroups - Finds all ancestor groups for a given widget HID
453
        '
454
        ' @param {string} HID - The Hierarchical ID of the widget
455
        ' @returns {object} An roArray of ancestor group HIDs, sorted with the immediate parent first (descending HID length)
456
        '
457
        function findAncestorGroups(HID as string) as object
458
            allGroups = m.groupStack.getAll() ' Get all registered groups
1✔
459
            ancestorGroups = []
1✔
460
            ' Iterate through all groups to find ancestors
461
            for each groupHID in allGroups
1✔
462
                if Rotor.Utils.isAncestorHID(groupHID, HID)
2✔
463
                    ancestorGroups.push(groupHID)
1✔
464
                end if
465
            end for
466
            ' Sort by HID length descending (parent first)
467
            ancestorGroups.Sort("r")
1✔
468

469
            ' Note:
470
            ' - Parent group is at index 0
471
            ' - If HID is a focusItem, its direct parent group is included
472
            ' - If HID is a group, the group itself is NOT included
473
            return ancestorGroups
1✔
474
        end function
475

476
        '
477
        ' removeFocusConfig - Removes focus configuration (Group and/or FocusItem) for a widget
478
        '
479
        ' @param {string} HID - The Hierarchical ID of the widget whose config should be removed
480
        '
481
        sub removeFocusConfig(HID as string)
482
            ' Remove associated group, if it exists
483
            if m.groupStack.has(HID)
2✔
484
                m.groupStack.remove(HID)
1✔
485
            end if
486
            ' Remove associated focus item, if it exists
487
            if m.focusItemStack.has(HID)
3✔
488
                m.focusItemStack.remove(HID)
1✔
489
            end if
490
        end sub
491

492
        '
493
        ' setFocus - Sets or removes focus from a specific widget or group
494
        '
495
        ' Handles focus state changes, callbacks, and native focus interaction.
496
        '
497
        ' @param {dynamic} ref - The target: HID (string) of a FocusItem or Group, or Node ID (string) of a Group
498
        ' @param {boolean} isFocused - True to set focus, false to remove focus (default: true)
499
        ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node (default: false)
500
        ' @returns {boolean} True if the focus state was successfully changed, false otherwise
501
        '
502
        function setFocus(ref as dynamic, isFocused = true as boolean, enableNativeFocus = false as boolean) as boolean
503

504
            ' Resolve reference (HID or ID) to a focusItem item.
505
            focusItem = invalid ' Initialize target focus item
1✔
506

507
            ' Exit if reference is empty or invalid.
508
            if ref = invalid or ref = "" then return false
2✔
509

510
            if m.focusItemStack.has(ref)
2✔
511
                ' Case 1: ref is a valid focusItem HID.
512
                focusItem = m.focusItemStack.get(ref)
1✔
513
            else
514
                ' Case 2: ref might be a focusItem node ID.
3✔
515
                focusItem = m.focusItemStack.getByNodeId(ref)
1✔
516

517
                if focusItem = invalid
3✔
518
                    ' Case 3: ref might be a group HID or group node ID.
519
                    ' Try finding group by HID first, then by Node ID.
520
                    group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
1✔
521
                    if group <> invalid
3✔
522
                        ' If group found, find its default/entry focus item recursively.
523
                        HID = m.capturingFocus_recursively(group.HID)
1✔
524
                        focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
1✔
525

526
                        ' else: ref is not a known FocusItem HID or Group identifier
527
                    end if
528
                end if
529
            end if
530

531
            ' Handle case where the target focus item could not be found or resolved.
532
            if focusItem = invalid
3✔
533
                focused = m.focusItemStack.get(m.globalFocusHID) ' Check current focus
1✔
534
                #if debug
4✔
535
                    ' Log warnings if focus target is not found
536
                    if focused = invalid
2✔
537
                        print `[PLUGIN][FOCUS][WARNING] Requested focus target ref: "${ref}" was not found or resolved to a valid FocusItem.`
×
538
                        if m.globalFocusHID = ""
×
539
                            ' If global focus is also lost, indicate potential issue.
540
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue likely. No current focus set. Ensure valid initial focus.`
×
541
                        else
×
542
                            print `[PLUGIN][FOCUS][WARNING] Current focus HID: "${m.globalFocusHID}". Ensure target "${ref}" is registered and reachable.`
×
543
                        end if
544
                    else
3✔
545
                        print `[PLUGIN][FOCUS][WARNING] Could not find focus target ref: "${ref}". Current focus remains on HID: "${m.globalFocusHID}", id"${m.globalFocusId}"".`
1✔
546
                    end if
547
                #end if
548
                return false ' Indicate focus change failed
1✔
549
            end if
550

551
            ' Found a valid focusItem to target
552
            HID = focusItem.HID
1✔
553

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

558
            ' Cannot focus an invisible item.
559
            if focusItem.node.visible = false and isFocused = true then return false
2✔
560

561
            ' Determine if native focus should be enabled (request or item default)
562
            enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
1✔
563

564
            ' Prevent focusing a disabled item.
565
            preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
1✔
566
            if preventFocusOnDisabled
2✔
567
                return false ' Indicate focus change failed
×
568
            end if
569

570
            ' Handle blurring the previously focused item
571
            lastFocusChainingGroups = []
1✔
572
            if m.globalFocusHID <> "" ' If something was focused before
2✔
573
                lastFocused = m.focusItemStack.get(m.globalFocusHID)
1✔
574
                if lastFocused <> invalid ' Check if the last focused widget hasn't been destroyed
3✔
575
                    ' Blur the previously focused item
576
                    lastFocused.applyFocus(false, enableNativeFocus)
1✔
577

578
                    ' Record the last focused item within its parent group for potential future use (e.g., returning focus)
579
                    lastFocusChainingGroups = m.findAncestorGroups(m.globalFocusHID)
1✔
580
                    if lastFocusChainingGroups.Count() > 0
3✔
581
                        parentGroupHID = lastFocusChainingGroups[0]
1✔
582
                        if parentGroupHID <> invalid and parentGroupHID <> ""
3✔
583
                            group = m.groupStack.get(parentGroupHID)
1✔
584
                            if group <> invalid
3✔
585
                                group.setLastFocusedHID(m.globalFocusHID)
1✔
586
                            end if
587
                        end if
588
                    end if
589
                end if
590
            end if
591

592
            ' Apply focus state (focused/blurred) to the target item.
593
            focusItem.applyFocus(isFocused, enableNativeFocus)
1✔
594

595
            ' Update focus state for ancestor groups (blur groups losing focus, focus groups gaining focus)
596
            focusChainGroups = m.findAncestorGroups(focusItem.HID) ' Groups containing the new focus
1✔
597
            focusChainGroups.append(lastFocusChainingGroups) ' Include groups containing the old focus
1✔
598
            Rotor.Utils.removeRedundantValuesInArray(focusChainGroups) ' Unique list of affected groups
1✔
599
            m.notifyFocusAtAncestorGroups(focusItem.HID, focusChainGroups) ' Notify all relevant groups
1✔
600

601
            ' Update the globally tracked focused item.
602
            m.storeGlobalFocusHID(isFocused ? HID : "", isFocused ? focusItem.id : "")
1✔
603

604
            ' Ensure SceneGraph root has focus if native focus wasn't explicitly enabled on the item.
605
            if enableNativeFocus = false
3✔
606
                globalScope = GetGlobalAA()
1✔
607
                if globalScope.top.isInFocusChain() = false
2✔
608
                    globalScope.top.setFocus(true)
1✔
609
                end if
610
            end if
611

612
            return true
1✔
613

614
        end function
615

616
        '
617
        ' notifyFocusAtAncestorGroups - Applies the correct focus state (in focus chain or not) to a list of group HIDs
618
        '
619
        ' @param {string} HID - The HID of the item that ultimately received/lost focus
620
        ' @param {object} groupHIDs - An roArray of group HIDs to notify
621
        '
622
        sub notifyFocusAtAncestorGroups(HID as string, groupHIDs = [] as object)
623

624
            ' Notify all ancestor groups
625
            if groupHIDs.Count() > 0
3✔
626
                for each groupHID in groupHIDs
1✔
627

628
                    group = m.groupStack.get(groupHID)
1✔
629
                    isInFocusChain = Rotor.Utils.isAncestorHID(groupHID, HID)
1✔
630
                    group.applyFocus(isInFocusChain)
1✔
631

632
                end for
633
            end if
634
        end sub
635

636
        sub notifyLongPressAtAncestorGroups(isLongPress as boolean, key as string, HID as string, groupHIDs = [] as object)
637
            ' Notify all ancestor groups
638
            if groupHIDs.Count() > 0
×
639
                for each groupHID in groupHIDs
×
640
                    group = m.groupStack.get(groupHID)
×
641
                    handled = group.callLongPressHandler(isLongPress, key)
×
642
                    if handled then exit for
×
643
                end for
644
            end if
645
        end sub
646

647
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
648
            focused = m.getFocusedItem()
×
649
            handled = focused.callLongPressHandler(isLongPress, key)
×
UNCOV
650
            if handled then return
×
651

652
            focusChainGroups = m.findAncestorGroups(focused.HID)
×
653
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
×
654
        end sub
655

656
        function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object) as string
657
            if focused.enableSpatialNavigation = false then return ""
2✔
658
            if direction = Rotor.Const.Direction.BACK then return ""
2✔
659

660
            ' Remove current focused item from candidates
661
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
1✔
662
            if index >= 0 then focusItemsHIDlist.delete(index)
1✔
663

664
            ' Find closest focusable item in direction
665
            segments = m.collectSegments(focused, direction, focusItemsHIDlist)
1✔
666
            if segments.Count() > 0
3✔
667
                return m.findClosestSegment(segments, focused.metrics.middlePoint)
1✔
668
            end if
669

670
            return ""
1✔
671
        end function
672

673
        function findClosestSegment(segments as object, middlePoint as object) as string
674
            distances = []
1✔
675

676
            ' Calculate distance from middle point to each segment
677
            for each HID in segments
1✔
678
                segment = segments[HID]
1✔
679
                distance = m.distanceCalculator.distToSegment(middlePoint, {
1✔
680
                    x: segment.x1,
681
                    y: segment.y1
682
                }, {
683
                    x: segment.x2,
684
                    y: segment.y2
685
                })
686

687
                distances.push({
1✔
688
                    HID: HID,
689
                    distance: distance
690
                })
691
            end for
692

693
            ' Find segment with minimum distance
694
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
1✔
695
                return a < b
696
            end function)
697

698
            return minDistItem.HID
1✔
699
        end function
700

701

702
        ' Waterfall of fallback's of groups (linked together with defaultFocusId)
703
        function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
704
            ' Resolve identifier to a group
705
            group = m.groupStack.get(identifier)
1✔
706
            if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
1✔
707
            if group = invalid then return ""
2✔
708

709
            ' Get fallback identifier for this group
710
            newHID = group.getFallbackIdentifier()
1✔
711

712
            ' Check if we found a FocusItem
713
            if m.focusItemStack.has(newHID)
3✔
714
                ' Apply spatial enter feature if enabled
715
                if group.enableSpatialEnter = true and direction <> ""
2✔
716
                    focused = m.focusItemStack.get(m.globalFocusHID)
×
717
                    newSpatialHID = m.spatialNavigation(focused, direction, group.getGroupMembersHIDs())
×
718
                    if newSpatialHID <> "" then newHID = newSpatialHID
×
719
                end if
720

721
            else if newHID <> ""
3✔
722
                ' Try to find as group first, then deep search
723
                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
1✔
724

725
                ' If still not found, perform deep search in all descendants
726
                if newHID = ""
3✔
727
                    newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
1✔
728
                end if
729
            end if
730

731
            ' Prevent capturing by fallback in the same group where original focus was
732
            if newHID <> "" and m.globalFocusHID <> ""
3✔
733
                currentAncestors = m.findAncestorGroups(m.globalFocusHID)
1✔
734
                newAncestors = m.findAncestorGroups(newHID)
1✔
735
                if currentAncestors.Count() > 0 and newAncestors.Count() > 0
3✔
736
                    if currentAncestors[0] = newAncestors[0] then newHID = ""
1✔
737
                end if
738
            end if
739

740
            return newHID
1✔
741
        end function
742

743
        '
744
        ' deepSearchFocusItemByNodeId - Deep search for a FocusItem or Group by nodeId within a group hierarchy
745
        '
746
        ' @param {string} groupHID - The HID of the group to search within
747
        ' @param {string} nodeId - The node ID to search for
748
        ' @returns {string} The HID of the found FocusItem or Group, or empty string if not found
749
        '
750
        function deepSearchFocusItemByNodeId(groupHID as string, nodeId as string) as string
751
            if nodeId = "" then return ""
2✔
752

753
            ' Get all descendants of this group (both FocusItems and nested Groups)
754
            allFocusItems = m.focusItemStack.getAll()
1✔
755
            allGroups = m.groupStack.getAll()
1✔
756

757
            ' First, search in direct and nested FocusItems
758
            for each focusItemHID in allFocusItems
1✔
759
                if Rotor.Utils.isDescendantHID(focusItemHID, groupHID)
3✔
760
                    focusItem = m.focusItemStack.get(focusItemHID)
1✔
761
                    if focusItem <> invalid and focusItem.id = nodeId
2✔
762
                        return focusItemHID
×
763
                    end if
764
                end if
765
            end for
766

767
            ' Second, search in nested Groups (and if found, apply fallback logic on that group)
768
            for each nestedGroupHID in allGroups
1✔
769
                if Rotor.Utils.isDescendantHID(nestedGroupHID, groupHID) and nestedGroupHID <> groupHID
3✔
770
                    nestedGroup = m.groupStack.get(nestedGroupHID)
1✔
771
                    if nestedGroup <> invalid and nestedGroup.id = nodeId
3✔
772
                        ' Found a matching group - now apply fallback logic on it
773
                        fallbackHID = nestedGroup.getFallbackIdentifier()
1✔
774
                        if m.focusItemStack.has(fallbackHID)
3✔
775
                            return fallbackHID
1✔
776
                        else if fallbackHID <> ""
×
777
                            ' Recursively resolve the fallback
778
                            return m.capturingFocus_recursively(fallbackHID, "", nestedGroupHID)
×
779
                        end if
780
                    end if
781
                end if
782
            end for
783

784
            return ""
×
785
        end function
786

787
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
788
            newHID = ""
1✔
789

790
            ' Build ancestor chain (current group + all ancestors)
791
            ancestorGroups = m.findAncestorGroups(groupHID)
1✔
792
            ancestorGroups.unshift(groupHID)
1✔
793
            ancestorGroupsCount = ancestorGroups.Count()
1✔
794
            ancestorIndex = 0
1✔
795

796
            ' Bubble up through ancestor groups until we find a target or reach the top
797
            while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
1✔
798
                ' Get next ancestor group
799
                groupHID = ancestorGroups[ancestorIndex]
1✔
800
                group = m.groupStack.get(groupHID)
1✔
801

802
                ' Check group's direction configuration
803
                nodeId = group.getStaticNodeIdInDirection(direction)
1✔
804

805
                if Rotor.Utils.isBoolean(nodeId)
2✔
806
                    ' Boolean means focus is explicitly handled
807
                    if nodeId = true
3✔
808
                        newHID = true ' Block navigation (exit loop)
1✔
809
                    else
×
810
                        newHID = "" ' Continue bubbling
×
811
                    end if
812
                else
813
                    ' String nodeId - try to resolve target
3✔
814
                    if nodeId <> ""
3✔
815
                        otherGroup = m.groupStack.getByNodeId(nodeId)
1✔
816
                        if otherGroup <> invalid
3✔
817
                            newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
1✔
818
                        end if
819
                    end if
820
                end if
821

822
                ancestorIndex++
1✔
823
            end while
824

825
            return newHID
1✔
826
        end function
827

828
        ' * KEY EVENT HANDLER
829
        function onKeyEventHandler(key as string, press as boolean) as object
830
            ' Check long-press
831
            if m.enableLongPressFeature = true
2✔
832
                m.checkLongPressState(key, press)
×
833
            end if
834
            ' Prevent any navigation if it is disabled
835
            #if debug
4✔
836
                if m.enableFocusNavigation = false and press = true then print "[PLUGIN][FOCUS][INFO] Focus navigation is disabled. Call enableFocusNavigation(true) to make it enabled"
2✔
837
            #end if
838
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
2✔
839
            ' Execute action according to key press
840
            return m.executeNavigationAction(key, press)
1✔
841
        end function
842

843
        function executeNavigationAction(key as string, press as boolean) as object
844

845
            if true = press
3✔
846

847
                if -1 < Rotor.Utils.findInArray([
3✔
848
                        Rotor.Const.Direction.UP,
849
                        Rotor.Const.Direction.RIGHT,
850
                        Rotor.Const.Direction.DOWN,
851
                        Rotor.Const.Direction.LEFT,
852
                        Rotor.Const.Direction.BACK
853
                    ], key)
854

855
                    newHID = ""
1✔
856
                    direction = key
1✔
857

858
                    ' (1) Pick up current focused item
859

860
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
861

862
                    if focused = invalid
2✔
863
                        #if debug
×
864
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue detected. Last known focus id:\"${m.globalFocusHID}\". Please ensure valid focus.`
×
865
                        #end if
866
                        return m.parseOnKeyEventResult(key, false, false)
×
867
                    end if
868

869

870
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
871
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
872

873
                    if ancestorGroupsCount = 0
2✔
874
                        allFocusItems = m.focusItemStack.getAll()
×
875
                        possibleFocusItems = allFocusItems.keys()
×
876
                        parentGroupHID = ""
×
877
                    else
3✔
878
                        parentGroupHID = ancestorGroups[0]
1✔
879
                        group = m.groupStack.get(parentGroupHID)
1✔
880
                        possibleFocusItems = group.getGroupMembersHIDs()
1✔
881
                    end if
882

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

886
                    if Rotor.Utils.isBoolean(nodeId) and nodeId = true
2✔
887
                        ' It means that focus is handled, and no need further action by plugin.
888
                        return m.parseOnKeyEventResult(key, true, false)
×
889
                    end if
890

891
                    if nodeId <> ""
2✔
892
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
893
                    end if
894

895
                    if newHID = ""
3✔
896
                        ' (3) Try spatial navigation in direction, among possible focusItems
897
                        ' all = m.focusItemStack.getAll()
898
                        ' allKeys = all.Keys()
899
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
1✔
900
                    end if
901

902
                    ' (4) Check if found group. FocusItem can not point out of group.
903
                    if newHID = "" and ancestorGroupsCount > 0 ' (5/2) If this focused has parent group, lets try bubbling focus on ancestors (groups)
2✔
904
                        newHID = m.bubblingFocus(parentGroupHID, direction)
1✔
905
                        if Rotor.Utils.isBoolean(newHID)
2✔
906
                            if newHID = true
3✔
907
                                ' It means that focus is handled, and no need further action by plugin.
908
                                return m.parseOnKeyEventResult(key, true, false)
1✔
909
                            else
×
910
                                newHID = ""
×
911
                            end if
912
                        end if
913
                    end if
914

915
                    handled = m.setFocus(newHID)
1✔
916
                    return m.parseOnKeyEventResult(key, handled, false)
1✔
917

918
                else if key = "OK"
×
919

920
                    return m.parseOnKeyEventResult(key, true, true)
×
921

922
                end if
923
            end if
924

925
            return m.parseOnKeyEventResult(key, false, false)
×
926

927
        end function
928

929
        function parseOnKeyEventResult(key as string, handled as boolean, isSelected as boolean) as object
930
            result = {
1✔
931
                handled: handled,
932
                key: key
933
            }
934
            if m.globalFocusHID <> "" and handled = true
3✔
935
                focusItem = m.focusItemStack.get(m.globalFocusHID)
1✔
936
                widget = m.widgetTree.get(focusItem.HID)
1✔
937
                ' viewModelState = Rotor.Utils.deepCopy(widget.viewModelState)
938
                result.widget = widget
1✔
939
                if isSelected
2✔
940
                    result.isSelected = isSelected
×
941
                    focusItem.callOnSelectedFnOnWidget()
×
942
                end if
943
            end if
944
            return result
1✔
945
        end function
946

947
        sub checkLongPressState(key as string, press as boolean)
948
            m.longPressTimer.control = "stop"
×
949
            if press = true
×
950
                if m.isLongPress = false
×
951
                    m.longPressKey = key
×
952
                    m.longPressTimer.control = "start"
×
953
                end if
954
            else
×
955
                wasLongPress = m.isLongPress = true
×
956
                lastKey = m.longPressKey
×
957
                m.isLongPress = false
×
958
                m.longPressKey = ""
×
959
                if wasLongPress
×
960
                    m.delegateLongPressChanged(false, lastKey)
×
961
                end if
962
            end if
963
        end sub
964

965
        function proceedLongPress() as object
966
            return m.executeNavigationAction(m.longPressKey, true)
×
967
        end function
968

969
        ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
970
        function collectSegments(focused as object, direction as string, focusItemsHIDlist as object) as object
971
            focused.refreshBounding()
1✔
972

973
            refSegmentTop = focused.metrics.segments[Rotor.Const.Segment.TOP]
1✔
974
            refSegmentRight = focused.metrics.segments[Rotor.Const.Segment.RIGHT]
1✔
975
            refSegmentLeft = focused.metrics.segments[Rotor.Const.Segment.LEFT]
1✔
976
            refSegmentBottom = focused.metrics.segments[Rotor.Const.Segment.BOTTOM]
1✔
977
            referencePoint = { x: (refSegmentTop.x1 + refSegmentRight.x2) / 2, y: (refSegmentTop.y1 + refSegmentRight.y2) / 2 }
1✔
978

979
            validators = {
1✔
980

981
                "left": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
982
                    right = segments[Rotor.Const.Segment.RIGHT]
983
                    ' Candidate's right edge must be strictly left of focused element's left edge
984
                    return right.x2 <= refSegmentLeft.x1 ? { isValid: true, segment: right } : { isValid: false }
985
                end function,
986

987
                "up": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
988
                    bottom = segments[Rotor.Const.Segment.BOTTOM]
989
                    ' Candidate's bottom edge must be strictly above focused element's top edge
990
                    return bottom.y2 <= refSegmentTop.y1 ? { isValid: true, segment: bottom } : { isValid: false }
991
                end function,
992

993
                "right": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
994
                    left = segments[Rotor.Const.Segment.LEFT]
995
                    ' Candidate's left edge must be strictly right of focused element's right edge
996
                    return left.x1 >= refSegmentRight.x2 ? { isValid: true, segment: left } : { isValid: false }
997
                end function,
998

999
                "down": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
1000
                    top = segments[Rotor.Const.Segment.TOP]
1001
                    ' Candidate's top edge must be strictly below focused element's bottom edge
1002
                    return top.y1 >= refSegmentBottom.y2 ? { isValid: true, segment: top } : { isValid: false }
1003
                end function
1004
            }
1005
            segments = {}
1✔
1006
            validator = validators[direction]
1✔
1007
            for each HID in focusItemsHIDlist
1✔
1008
                if HID <> focused.HID
3✔
1009
                    focusItem = m.focusItemStack.get(HID)
1✔
1010
                    focusItem.refreshBounding()
1✔
1011
                    ' Pass appropriate reference segments based on direction
1012
                    if direction = "left" or direction = "right"
3✔
1013
                        result = validator(referencePoint, focusItem.metrics.segments, refSegmentLeft, refSegmentRight)
1✔
1014
                    else ' up or down
3✔
1015
                        result = validator(referencePoint, focusItem.metrics.segments, refSegmentTop, refSegmentBottom)
1✔
1016
                    end if
1017
                    if result.isValid
3✔
1018
                        segments[HID] = result.segment
1✔
1019
                    end if
1020
                end if
1021
            end for
1022

1023
            return segments
1✔
1024
        end function
1025

1026
        sub destroy()
1027
            ' Remove all groups
1028
            for each HID in m.groupStack.getAll()
×
1029
                m.groupStack.remove(HID)
×
1030
            end for
1031
            ' Remove all focus items
1032
            for each HID in m.focusItemStack.getAll()
×
1033
                m.focusItemStack.remove(HID)
×
1034
            end for
1035
            m.longPressTimer.unobserveFieldScoped("fire")
×
1036
            m.longPressTimer = invalid
×
1037
            m.widgetTree = invalid
×
1038
        end sub
1039

1040
    end class
1041

1042
    namespace FocusPluginHelper
1043

1044
        class BaseEntryStack extends Rotor.BaseStack
1045

1046
            function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
1047
                if ancestorHID <> "0"
3✔
1048
                    filteredStack = {}
1✔
1049
                    for each HID in m.stack
1✔
1050
                        if Rotor.Utils.isDescendantHID(HID, ancestorHID)
3✔
1051
                            filteredStack[HID] = m.get(HID)
1✔
1052
                        end if
1053
                    end for
1054
                else
3✔
1055
                    filteredStack = m.stack
1✔
1056
                end if
1057
                HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
1✔
1058
                return HID <> "" ? m.get(HID) : invalid
1✔
1059
            end function
1060

1061
            override sub remove(HID as string)
1062
                item = m.get(HID)
1✔
1063
                item.destroy()
1✔
1064
                super.remove(HID)
1✔
1065
            end sub
1066

1067
        end class
1068

1069
        class GroupStack extends BaseEntryStack
1070

1071
            function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1072
                foundHID = ""
×
1073
                for each HID in possibleGroups
×
1074
                    group = m.get(HID)
×
1075
                    if group.id = nodeId
×
1076
                        foundHID = group.HID
×
1077
                        exit for
1078
                    end if
1079
                end for
1080
                return foundHID
×
1081
            end function
1082

1083
        end class
1084

1085

1086
        class FocusItemStack extends BaseEntryStack
1087

1088
            function convertNodeIdToHID(nodeId as string, possibleFocusItems as object) as string
1089
                foundHID = ""
×
1090
                for each HID in possibleFocusItems
×
1091
                    focusItem = m.get(HID)
×
1092
                    if focusItem.id = nodeId
×
1093
                        foundHID = focusItem.HID
×
1094
                        exit for
1095
                    end if
1096
                end for
1097
                return foundHID
×
1098
            end function
1099

1100
            function hasEnabled(HID as string) as boolean
1101
                if m.has(HID)
×
1102
                    focusItem = m.get(HID)
×
1103
                    return focusItem.isEnabled
×
1104
                else
×
1105
                    return false
×
1106
                end if
1107
            end function
1108

1109
        end class
1110

1111
        class BaseFocusConfig
1112

1113
            autoSetIsFocusedOnContext as boolean
1114
            staticDirection as object
1115

1116
            sub new (config as object)
1117

1118
                m.HID = config.HID
1✔
1119
                m.id = config.id
1✔
1120

1121
                m.widget = config.widget
1✔
1122
                m.node = m.widget.node
1✔
1123
                m.isFocused = config.isFocused ?? false
1✔
1124

1125
                m.autoSetIsFocusedOnContext = config.autoSetIsFocusedOnContext ?? true
1✔
1126

1127
                m.isEnabled = config.isEnabled ?? true
1✔
1128
                m.staticDirection = {}
1✔
1129
                m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1✔
1130
                m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
1✔
1131
                m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
1✔
1132
                m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
1✔
1133
                m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
1✔
1134

1135
                m.onFocusChanged = config.onFocusChanged
1✔
1136
                m.longPressHandler = config.longPressHandler
1✔
1137
                m.onFocus = config.onFocus
1✔
1138

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

1141
                ' convenience (usually this is used on viewModelState)
1142
                if false = m.widget.viewModelState.DoesExist("isFocused") and true = m.autoSetIsFocusedOnContext
2✔
1143
                    m.widget.viewModelState.isFocused = false ' as default
1✔
1144
                end if
1145

1146
            end sub
1147

1148

1149
            HID as string
1150
            id as string
1151
            idByKeys as object
1152
            isEnabled as boolean
1153
            isFocused as boolean
1154
            onFocusChanged as dynamic
1155
            onFocus as dynamic
1156
            longPressHandler as dynamic
1157
            node as object
1158
            widget as object
1159

1160
            function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1161
                direction = m.staticDirection[direction]
1✔
1162
                if Rotor.Utils.isFunction(direction)
2✔
1163
                    return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
×
1164
                else
3✔
1165
                    return direction ?? ""
1✔
1166
                end if
1167
            end function
1168

1169
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1170
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1171
                if true = isFocused
3✔
1172
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1173
                end if
1174
            end sub
1175

1176
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1177
                if Rotor.Utils.isFunction(m.longPressHandler)
×
1178
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1179
                else
×
1180
                    return false
×
1181
                end if
1182
            end function
1183

1184
            sub destroy()
1185
                m.widget = invalid
1✔
1186
                m.node = invalid
1✔
1187
                m.onFocusChanged = invalid
1✔
1188
                m.longPressHandler = invalid
1✔
1189
            end sub
1190

1191
        end class
1192

1193
        class GroupClass extends BaseFocusConfig
1194
            ' Note: Spatial navigation is supported within group, there is no spatial navigation between groups
1195
            ' If you want to focus out to another group, you need to config a direction prop.
1196
            ' You can set a groupId or any focusItem widgetId.
1197
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1198
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1199

1200
            sub new (config as object)
1201
                super(config)
1✔
1202
                m.defaultFocusId = config.defaultFocusId ?? ""
1✔
1203
                m.lastFocusedHID = config.lastFocusedHID ?? ""
1✔
1204
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
1✔
1205
            end sub
1206

1207
            defaultFocusId as string
1208
            lastFocusedHID as string
1209
            enableSpatialEnter as boolean
1210
            focusItemsRef as object
1211
            groupsRef as object
1212

1213
            isFocusItem = false
1214
            isGroup = true
1215

1216
            sub setLastFocusedHID(lastFocusedHID as string)
1217
                m.lastFocusedHID = lastFocusedHID
1✔
1218
            end sub
1219

1220
            function getGroupMembersHIDs()
1221
                ' Collect all focusItems that are descendants of this group
1222
                ' Exclude items that belong to nested sub-groups
1223
                focusItems = m.focusItemsRef.getAll()
1✔
1224
                groups = m.groupsRef.getAll()
1✔
1225
                HIDlen = Len(m.HID)
1✔
1226
                collection = []
1✔
1227
                groupsKeys = groups.keys()
1✔
1228
                groupsCount = groups.Count()
1✔
1229

1230
                for each focusItemHID in focusItems
1✔
1231
                    ' Check if focusItem is a descendant of this group
1232
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
1✔
1233
                    if isDescendant
2✔
1234
                        ' Check if focusItem belongs to a nested sub-group
1235
                        shouldExclude = false
1✔
1236
                        otherGroupIndex = 0
1✔
1237
                        while shouldExclude = false and otherGroupIndex < groupsCount
1✔
1238
                            otherGroupHID = groupsKeys[otherGroupIndex]
1✔
1239
                            otherGroupHIDlen = Len(otherGroupHID)
1✔
1240
                            ' Exclude if belongs to deeper nested group
1241
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
1✔
1242
                            otherGroupIndex++
1✔
1243
                        end while
1244

1245
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1246
                    end if
1247
                end for
1248

1249
                return collection
1✔
1250
            end function
1251

1252
            '
1253
            ' getFallbackNodeId - Returns the nodeId to use for fallback (defaultFocusId or lastFocusedHID)
1254
            '
1255
            ' @returns {string} The nodeId to use for fallback, or empty string if none
1256
            '
1257
            function getFallbackNodeId() as string
1258
                if m.lastFocusedHID <> ""
2✔
1259
                    ' Note: lastFocusedHID is already a HID, not a nodeId, so we need to get the nodeId
1260
                    lastFocusedItem = m.focusItemsRef.get(m.lastFocusedHID)
×
1261
                    if lastFocusedItem <> invalid
×
1262
                        return lastFocusedItem.id
×
1263
                    end if
1264
                end if
1265

1266
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1267
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1268
                else
3✔
1269
                    return m.defaultFocusId
1✔
1270
                end if
1271
            end function
1272

1273
            function getFallbackIdentifier() as string
1274
                HID = ""
1✔
1275
                if m.lastFocusedHID <> ""
2✔
1276
                    return m.lastFocusedHID
×
1277
                else
3✔
1278
                    if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1279
                        defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1280
                    else
3✔
1281
                        defaultFocusId = m.defaultFocusId
1✔
1282
                    end if
1283

1284
                    if defaultFocusId <> ""
3✔
1285
                        focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1286
                        if focusItemsHIDlist.Count() > 0
3✔
1287

1288
                            ' Try find valid HID in focusItems by node id
1289
                            focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1✔
1290
                            if focusItemHID <> ""
3✔
1291
                                HID = focusItemHID
1✔
1292
                            end if
1293

1294
                        else
1295

3✔
1296
                            return defaultFocusId
1✔
1297

1298
                        end if
1299
                    end if
1300

1301
                end if
1302

1303
                return HID
1✔
1304
            end function
1305

1306
            function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1307
                HID = ""
1✔
1308
                for each HID in focusItemsHIDlist
1✔
1309
                    focusItem = m.focusItemsRef.get(HID)
1✔
1310
                    if focusItem <> invalid and focusItem.id = nodeId
3✔
1311
                        HID = focusItem.HID
1✔
1312
                        exit for
1313
                    end if
1314
                end for
1315
                return HID
1✔
1316
            end function
1317

1318
            sub applyFocus(isFocused as boolean)
1319
                if m.isFocused = isFocused then return
2✔
1320

1321
                m.isFocused = isFocused
1✔
1322

1323
                if m.autoSetIsFocusedOnContext
3✔
1324
                    m.widget.viewModelState.isInFocusChain = isFocused
1✔
1325
                end if
1326
                m.node.setField("isFocused", isFocused)
1✔
1327
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1328
            end sub
1329

1330
            override sub destroy()
1331
                super.destroy()
1✔
1332
                m.focusItemsRef = invalid
1✔
1333
                m.groupsRef = invalid
1✔
1334
            end sub
1335

1336

1337

1338
        end class
1339

1340
        class FocusItemClass extends BaseFocusConfig
1341

1342
            sub new (config as object)
1343
                super(config)
1✔
1344

1345
                m.onSelected = config.onSelected ?? ""
1✔
1346
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
1✔
1347
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1348
            end sub
1349

1350
            ' You can set a groupId or any focusItem widgetId.
1351
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1352
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1353

1354
            ' key as string
1355
            isFocusItem = true
1356
            isGroup = false
1357
            enableNativeFocus as boolean
1358
            enableSpatialNavigation as boolean
1359
            onSelected as dynamic
1360

1361
            private metrics = {
1362
                segments: {}
1363
            }
1364
            private bounding as object
1365

1366

1367
            sub refreshBounding()
1368
                b = m.node.sceneBoundingRect()
1✔
1369
                rotation = m.node.rotation
1✔
1370

1371
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1372
                ' That is why we can use translation without knowing the value of inheritParentTransform
1373
                ' If bounding x or y are not zero, then bounding will include the node's translation
1374
                if rotation = 0
3✔
1375
                    if b.y = 0 and b.x = 0
2✔
1376
                        t = m.node.translation
×
1377
                        b.x += t[0]
×
1378
                        b.y += t[1]
×
1379
                    end if
1380

1381
                    m.metrics.append(b)
1✔
1382
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
1✔
1383
                        x1: b.x, y1: b.y,
1384
                        x2: b.x, y2: b.y + b.height
1385
                    }
1386
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
1✔
1387
                        x1: b.x, y1: b.y,
1388
                        x2: b.x + b.width, y2: b.y
1389
                    }
1390
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
1✔
1391
                        x1: b.x + b.width, y1: b.y,
1392
                        x2: b.x + b.width, y2: b.y + b.height
1393
                    }
1394
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
1✔
1395
                        x1: b.x, y1: b.y + b.height,
1396
                        x2: b.x + b.width, y2: b.y + b.height
1397
                    }
1398
                    m.metrics.middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
1✔
1399
                else
×
1400
                    scaleRotateCenter = m.node.scaleRotateCenter
×
1401
                    dims = m.node.localBoundingRect() ' We need this to get proper (rotated value of rotated x and y)
×
1402
                    if b.y = 0 and b.x = 0
×
1403
                        t = m.node.translation
×
1404
                        b.x += t[0]
×
1405
                        b.y += t[1]
×
1406
                    end if
1407
                    b.width = dims.width
×
1408
                    b.height = dims.height
×
1409
                    m.metrics.append(b)
×
1410

1411
                    ' Calculate rotated segments
1412
                    segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
×
1413
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1414
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
×
1415

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

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

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

1428
                    ' Calculate rotated middle point
1429
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1430
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1431
                    m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
×
1432

1433
                end if
1434
            end sub
1435

1436
            override sub destroy()
1437
                m.onSelected = invalid
1✔
1438
                m.metrics.segments.Clear()
1✔
1439
                super.destroy()
1✔
1440
            end sub
1441

1442
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1443
                if m.isFocused = isFocused then return
2✔
1444

1445
                m.isFocused = isFocused
1✔
1446

1447
                if m.autoSetIsFocusedOnContext
3✔
1448
                    m.widget.viewModelState.isFocused = isFocused
1✔
1449
                end if
1450

1451
                m.node.setField("isFocused", isFocused)
1✔
1452

1453
                if enableNativeFocus or m.enableNativeFocus
2✔
1454
                    m.node.setFocus(isFocused)
×
1455
                end if
1456

1457
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1458

1459
            end sub
1460

1461
            sub callOnSelectedFnOnWidget()
1462
                Rotor.Utils.callbackScoped(m.onSelected, m.widget)
×
1463
            end sub
1464

1465
        end class
1466

1467
        class ClosestSegmentToPointCalculatorClass
1468

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

1472
                A = x - x1
1✔
1473
                B = y - y1
1✔
1474
                C = x2 - x1
1✔
1475
                D = y2 - y1
1✔
1476

1477
                dot = A * C + B * D
1✔
1478
                len_sq = C * C + D * D
1✔
1479
                param = -1
1✔
1480
                if len_sq <> 0
3✔
1481
                    param = dot / len_sq
1✔
1482
                end if
1483

1484
                xx = 0
1✔
1485
                yy = 0
1✔
1486

1487
                if param < 0
2✔
1488
                    xx = x1
×
1489
                    yy = y1
×
1490
                else if param > 1
2✔
1491
                    xx = x2
×
1492
                    yy = y2
×
1493
                else
3✔
1494
                    xx = x1 + param * C
1✔
1495
                    yy = y1 + param * D
1✔
1496
                end if
1497

1498
                dx = x - xx
1✔
1499
                dy = y - yy
1✔
1500
                return dx * dx + dy * dy
1✔
1501
            end function
1502

1503
            function distToSegment(p as object, s1 as object, s2 as object)
1504
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1505
            end function
1506

1507
        end class
1508

1509
    end namespace
1510

1511
    namespace FocusPluginHelper
1512

1513
        sub longPressObserverCallback(msg)
1514
            extraInfo = msg.GetInfo()
×
1515

1516
            pluginKey = extraInfo["pluginKey"]
×
1517

1518
            globalScope = GetGlobalAA()
×
1519
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
×
1520
            plugin = frameworkInstance.plugins[pluginKey]
×
1521
            plugin.isLongPress = true
×
1522
            ' plugin.longPressStartHID = plugin.globalFocusHID
1523
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
×
1524

1525
        end sub
1526

1527
    end namespace
1528

1529
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