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

mobalazs / rotor-framework / 20115355396

10 Dec 2025 10:30PM UTC coverage: 84.655% (-0.4%) from 85.017%
20115355396

push

github

mobalazs
fix(FocusPlugin): remove unnecessary else clause in focus item retrieval logic

1986 of 2346 relevant lines covered (84.65%)

1.17 hits per line

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

78.05
/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

167
    const PRIMARY_FOCUS_PLUGIN_KEY = "focus"
168
    const GROUP_FOCUS_PLUGIN_KEY = "focusGroup"
169
    class FocusPlugin extends Rotor.BasePlugin
170

171
        pluginKey = PRIMARY_FOCUS_PLUGIN_KEY
172
        aliasPluginKey = GROUP_FOCUS_PLUGIN_KEY
173

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

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

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

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

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

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

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

234
                scope.setFocusConfig(widget, widget[targetKey])
×
235
            end sub,
236

237
            ' ---------------------------------------------------------------------
238
            ' beforeDestroy - Hook executed before a widget is destroyed
239
            '
240
            ' Removes focus config.
241
            '
242
            ' @param {object} scope - The plugin scope (this instance)
243
            ' @param {object} widget - The widget being destroyed
244
            '
245
            beforeDestroy: sub(scope as object, widget as object)
246
                scope.removeFocusConfig(widget.HID)
1✔
247
            end sub
248
        }
249

250
        ' Widget methods - Injected into widgets managed by this plugin
251
        widgetMethods = {
252

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

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

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

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

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

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

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

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

326
        }
327

328
        ' Configuration
329
        longPressDuration = 0.4
330
        enableLongPressFeature = true
331
        enableFocusNavigation = true
332

333
        ' State tracking
334
        globalFocusHID = ""
335
        globalFocusId = ""
336
        isLongPress = false
337
        longPressKey = ""
338

339
        ' References
340
        widgetTree as object
341
        frameworkInstance as Rotor.Framework
342

343
        ' Helper objects
344
        focusItemStack = new Rotor.FocusPluginHelper.FocusItemStack()
345
        groupStack = new Rotor.FocusPluginHelper.GroupStack()
346
        distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass()
347
        longPressTimer = CreateObject("roSGNode", "Timer")
348

349
        ' ---------------------------------------------------------------------
350
        ' init - Initializes the plugin instance
351
        '
352
        ' Sets up internal state and helpers.
353
        '
354
        sub init ()
355
            m.widgetTree = m.frameworkInstance.builder.widgetTree ' Reference to the main widget tree
1✔
356
            m.longPressTimer.addField("pluginKey", "string", false)
1✔
357
            m.longPressTimer.setFields({
1✔
358
                "pluginKey": m.pluginKey,
359
                duration: m.longPressDuration
360
            })
361
            ' Observe timer fire event to handle long press callback
362
            m.longPressTimer.observeFieldScoped("fire", "Rotor_FocusPluginHelper_longPressObserverCallback", ["pluginKey"])
1✔
363
        end sub
364

365
        '
366
        ' storeGlobalFocusHID - Stores the globally focused widget's HID and ID
367
        '
368
        ' @param {string} HID - The Hierarchical ID of the focused widget
369
        ' @param {string} id - The regular ID of the focused widget
370
        '
371
        sub storeGlobalFocusHID(HID as string, id as string)
372
            ' Store focus reference within the plugin
373
            m.globalFocusHID = HID
1✔
374
            m.globalFocusId = id
1✔
375
        end sub
376

377
        '
378
        ' getFocusedWidget - Gets the widget instance that currently holds global focus
379
        '
380
        ' @returns {object} The focused widget object, or invalid if none
381
        '
382
        function getFocusedWidget() as object
383
            return m.getFocusedItem()?.widget
1✔
384
        end function
385

386
        '
387
        ' getFocusedItem - Gets the FocusItem instance corresponding to the globally focused widget
388
        '
389
        ' @returns {object} The FocusItem instance, or invalid if none
390
        '
391
        function getFocusedItem() as object
392
            return m.focusItemStack.get(m.globalFocusHID)
1✔
393
        end function
394

395
        '
396
        ' setFocusConfig - Configures focus properties (FocusItem and/or Group) for a widget
397
        '
398
        ' @param {object} widget - The widget to configure
399
        ' @param {object} pluginConfig - The focus configuration object from the widget's spec
400
        '
401
        sub setFocusConfig(widget as object, pluginConfig as object)
402

403
            if pluginConfig = invalid then return ' No config provided
2✔
404
            HID = widget.HID
1✔
405
            id = widget.id
1✔
406

407
            ' Make a copy to avoid modifying the original config
408
            config = Rotor.Utils.deepCopy(pluginConfig)
1✔
409

410
            ' Ensure essential identifiers are in the config
411
            config.id = id
1✔
412
            config.HID = widget.HID
1✔
413

414
            ' Handle group configuration if present
415
            if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY)
3✔
416
                ' Handle focus item configuration if applicable
417
                m.setupFocusItem(HID, config, widget)
1✔
418
            else
419
                ' Handle group configuration
3✔
420
                m.setupGroup(HID, config, widget)
1✔
421
            end if
422
        end sub
423

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

443
        '
444
        ' setupFocusItem - Creates and registers a new Focus Item based on configuration
445
        '
446
        ' @param {string} HID - The Hierarchical ID of the focusItem widget
447
        ' @param {object} config - The full focus configuration for the widget
448
        ' @param {object} widget - The widget instance itself
449
        '
450
        sub setupFocusItem(HID as string, config as object, widget as object)
451
            config.widget = widget ' Ensure widget reference is in the config
1✔
452

453
            ' Create and register the FocusItem instance
454
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
1✔
455
            m.focusItemStack.set(HID, newFocusItem)
1✔
456
        end sub
457

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

476
            ' Note:
477
            ' - Parent group is at index 0
478
            ' - If HID is a focusItem, its direct parent group is included
479
            ' - If HID is a group, the group itself is NOT included
480
            return ancestorGroups
1✔
481
        end function
482

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

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

511
            ' Resolve reference (HID or ID) to a focusItem item.
512
            focusItem = invalid ' Initialize target focus item
1✔
513

514
            ' Exit if reference is empty or invalid.
515
            if ref = invalid or ref = "" then return false
2✔
516

517
            if m.focusItemStack.has(ref)
2✔
518
                ' Case 1: ref is a valid focusItem HID.
519
                focusItem = m.focusItemStack.get(ref)
1✔
520
            else
521
                ' Case 2: ref might be a focusItem node ID.
3✔
522
                focusItem = m.focusItemStack.getByNodeId(ref)
1✔
523

524
                if focusItem = invalid
3✔
525
                    ' Case 3: ref might be a group HID or group node ID.
526
                    ' Try finding group by HID first, then by Node ID.
527
                    group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
1✔
528
                    if group <> invalid
3✔
529
                        ' If group found, find its default/entry focus item recursively.
530
                        HID = m.capturingFocus_recursively(group.HID)
1✔
531
                        focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
1✔
532
                        ' else: ref is not a known FocusItem HID or Group identifier
533
                    end if
534
                end if
535
            end if
536

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

557
            ' Found a valid focusItem to target
558
            HID = focusItem.HID
1✔
559

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

564
            ' Cannot focus an invisible item.
565
            if focusItem.node.visible = false and isFocused = true then return false
2✔
566

567
            ' Determine if native focus should be enabled (request or item default)
568
            enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
1✔
569

570
            ' Prevent focusing a disabled item.
571
            preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
1✔
572
            if preventFocusOnDisabled
2✔
573
                return false ' Indicate focus change failed
×
574
            end if
575

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

579
            lastFocusChainingGroups = []
1✔
580

581
            ' Handle blurring the previously focused item
582
            if m.globalFocusHID <> "" ' If something was focused before
2✔
583
                lastFocused = m.focusItemStack.get(m.globalFocusHID)
1✔
584
                if lastFocused <> invalid ' Check if the last focused widget hasn't been destroyed
3✔
585
                    ' Record the last focused item within its parent group for potential future use (e.g., returning focus)
586
                    lastFocusChainingGroups = m.findAncestorGroups(m.globalFocusHID)
1✔
587
                    if lastFocusChainingGroups.Count() > 0
3✔
588
                        parentGroupHID = lastFocusChainingGroups[0]
1✔
589
                        if parentGroupHID <> invalid and parentGroupHID <> ""
3✔
590
                            group = m.groupStack.get(parentGroupHID)
1✔
591
                            if group <> invalid
3✔
592
                                group.setLastFocusedHID(m.globalFocusHID)
1✔
593
                            end if
594
                        end if
595
                    end if
596
                end if
597
            end if
598

599
            ' Prepare notification list: all affected groups (unique)
600
            allAffectedGroups = []
1✔
601
            for each groupHID in focusChainGroups
1✔
602
                allAffectedGroups.unshift(groupHID) ' Add in reverse order (highest ancestor first)
1✔
603
            end for
604
            for each groupHID in lastFocusChainingGroups
1✔
605
                if -1 = Rotor.Utils.findInArray(allAffectedGroups, groupHID)
2✔
606
                    allAffectedGroups.unshift(groupHID) ' Add in reverse order if not already present
1✔
607
                end if
608
            end for
609

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

613
            ' Blur the previously focused item (after notification)
614
            if m.globalFocusHID <> "" and lastFocused <> invalid
2✔
615
                lastFocused.applyFocus(false, enableNativeFocus)
1✔
616
            end if
617

618
            ' Apply focus state (focused/blurred) to the target item.
619
            focusItem.applyFocus(isFocused, enableNativeFocus)
1✔
620

621
            ' Update the globally tracked focused item.
622
            m.storeGlobalFocusHID(isFocused ? HID : "", isFocused ? focusItem.id : "")
1✔
623

624
            ' Ensure SceneGraph root has focus if native focus wasn't explicitly enabled on the item.
625
            if enableNativeFocus = false
3✔
626
                globalScope = GetGlobalAA()
1✔
627
                if globalScope.top.isInFocusChain() = false
2✔
628
                    globalScope.top.setFocus(true)
1✔
629
                end if
630
            end if
631

632
            return true
1✔
633

634
        end function
635

636
        '
637
        ' notifyFocusAtAncestorGroups - Applies the correct focus state (in focus chain or not) to a list of group HIDs
638
        '
639
        ' @param {string} HID - The HID of the item that ultimately received/lost focus
640
        ' @param {object} groupHIDs - An roArray of group HIDs to notify
641
        '
642
        sub notifyFocusAtAncestorGroups(HID as string, groupHIDs = [] as object)
643

644
            ' Notify all ancestor groups
645
            if groupHIDs.Count() > 0
3✔
646
                for each groupHID in groupHIDs
1✔
647

648
                    group = m.groupStack.get(groupHID)
1✔
649
                    isInFocusChain = Rotor.Utils.isAncestorHID(groupHID, HID)
1✔
650
                    group.applyFocus(isInFocusChain)
1✔
651

652
                end for
653
            end if
654
        end sub
655

656
        sub notifyLongPressAtAncestorGroups(isLongPress as boolean, key as string, HID as string, groupHIDs = [] as object)
657
            ' Notify all ancestor groups
658
            if groupHIDs.Count() > 0
3✔
659
                for each groupHID in groupHIDs
1✔
660
                    group = m.groupStack.get(groupHID)
1✔
661
                    handled = group.callLongPressHandler(isLongPress, key)
1✔
662
                    if handled then exit for
2✔
663
                end for
664
            end if
665
        end sub
666

667
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
668
            focused = m.getFocusedItem()
1✔
669
            handled = focused.callLongPressHandler(isLongPress, key)
1✔
670
            if handled then return
2✔
671

672
            focusChainGroups = m.findAncestorGroups(focused.HID)
1✔
673
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
1✔
674
        end sub
675

676
        function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object) as string
677
            if focused.enableSpatialNavigation = false then return ""
2✔
678
            if direction = Rotor.Const.Direction.BACK then return ""
2✔
679

680
            ' Remove current focused item from candidates
681
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
1✔
682
            if index >= 0 then focusItemsHIDlist.delete(index)
1✔
683

684
            ' Find closest focusable item in direction
685
            segments = m.collectSegments(focused, direction, focusItemsHIDlist)
1✔
686
            if segments.Count() > 0
3✔
687
                return m.findClosestSegment(segments, focused.metrics.middlePoint)
1✔
688
            end if
689

690
            return ""
1✔
691
        end function
692

693
        function findClosestSegment(segments as object, middlePoint as object) as string
694
            distances = []
1✔
695

696
            ' Calculate distance from middle point to each segment
697
            for each HID in segments
1✔
698
                segment = segments[HID]
1✔
699
                distance = m.distanceCalculator.distToSegment(middlePoint, {
1✔
700
                    x: segment.x1,
701
                    y: segment.y1
702
                }, {
703
                    x: segment.x2,
704
                    y: segment.y2
705
                })
706

707
                distances.push({
1✔
708
                    HID: HID,
709
                    distance: distance
710
                })
711
            end for
712

713
            ' Find segment with minimum distance
714
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
1✔
715
                return a < b
716
            end function)
717

718
            return minDistItem.HID
1✔
719
        end function
720

721

722
        ' Waterfall of fallback's of groups (linked together with defaultFocusId)
723
        function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
724
            ' Resolve identifier to a group
725
            group = m.groupStack.get(identifier)
1✔
726
            if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
1✔
727
            if group = invalid then return ""
2✔
728

729
            ' Get fallback identifier for this group
730
            newHID = group.getFallbackIdentifier()
1✔
731

732
            ' Check if we found a FocusItem
733
            if m.focusItemStack.has(newHID)
3✔
734
                ' Apply spatial enter feature if enabled
735
                if group.enableSpatialEnter = true and direction <> ""
2✔
736
                    focused = m.focusItemStack.get(m.globalFocusHID)
×
737
                    newSpatialHID = m.spatialNavigation(focused, direction, group.getGroupMembersHIDs())
×
738
                    if newSpatialHID <> "" then newHID = newSpatialHID
×
739
                end if
740

741
            else if newHID <> ""
3✔
742
                ' Try to find as group first, then deep search
743
                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
1✔
744

745
                ' If still not found, perform deep search in all descendants
746
                if newHID = ""
2✔
747
                    newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
1✔
748
                end if
749
            end if
750

751
            ' Prevent capturing by fallback in the same group where original focus was
752
            if newHID <> "" and m.globalFocusHID <> ""
2✔
753
                currentAncestors = m.findAncestorGroups(m.globalFocusHID)
1✔
754
                newAncestors = m.findAncestorGroups(newHID)
1✔
755
                if currentAncestors.Count() > 0 and newAncestors.Count() > 0
3✔
756
                    if currentAncestors[0] = newAncestors[0] then newHID = ""
1✔
757
                end if
758
            end if
759

760
            return newHID
1✔
761
        end function
762

763
        '
764
        ' deepSearchFocusItemByNodeId - Deep search for a FocusItem or Group by nodeId within a group hierarchy
765
        '
766
        ' @param {string} groupHID - The HID of the group to search within
767
        ' @param {string} nodeId - The node ID to search for
768
        ' @returns {string} The HID of the found FocusItem or Group, or empty string if not found
769
        '
770
        function deepSearchFocusItemByNodeId(groupHID as string, nodeId as string) as string
771
            if nodeId = "" then return ""
2✔
772

773
            ' Get all descendants of this group (both FocusItems and nested Groups)
774
            allFocusItems = m.focusItemStack.getAll()
1✔
775
            allGroups = m.groupStack.getAll()
1✔
776

777
            ' First, search in direct and nested FocusItems
778
            for each focusItemHID in allFocusItems
1✔
779
                if Rotor.Utils.isDescendantHID(focusItemHID, groupHID)
3✔
780
                    focusItem = m.focusItemStack.get(focusItemHID)
1✔
781
                    if focusItem <> invalid and focusItem.id = nodeId
2✔
782
                        return focusItemHID
×
783
                    end if
784
                end if
785
            end for
786

787
            ' Second, search in nested Groups (and if found, apply fallback logic on that group)
788
            for each nestedGroupHID in allGroups
1✔
789
                if Rotor.Utils.isDescendantHID(nestedGroupHID, groupHID) and nestedGroupHID <> groupHID
3✔
790
                    nestedGroup = m.groupStack.get(nestedGroupHID)
1✔
791
                    if nestedGroup <> invalid and nestedGroup.id = nodeId
3✔
792
                        ' Found a matching group - now apply fallback logic on it
793
                        fallbackHID = nestedGroup.getFallbackIdentifier()
1✔
794
                        if m.focusItemStack.has(fallbackHID)
3✔
795
                            return fallbackHID
1✔
796
                        else if fallbackHID <> ""
×
797
                            ' Recursively resolve the fallback
798
                            return m.capturingFocus_recursively(fallbackHID, "", nestedGroupHID)
×
799
                        end if
800
                    end if
801
                end if
802
            end for
803

804
            return ""
×
805
        end function
806

807
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
808
            newHID = ""
1✔
809

810
            ' Build ancestor chain (current group + all ancestors)
811
            ancestorGroups = m.findAncestorGroups(groupHID)
1✔
812
            ancestorGroups.unshift(groupHID)
1✔
813
            ancestorGroupsCount = ancestorGroups.Count()
1✔
814
            ancestorIndex = 0
1✔
815

816
            ' Bubble up through ancestor groups until we find a target or reach the top
817
            while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
1✔
818
                ' Get next ancestor group
819
                groupHID = ancestorGroups[ancestorIndex]
1✔
820
                group = m.groupStack.get(groupHID)
1✔
821

822
                ' Check group's direction configuration
823
                nodeId = group.getStaticNodeIdInDirection(direction)
1✔
824

825
                if Rotor.Utils.isBoolean(nodeId)
2✔
826
                    ' Boolean means focus is explicitly handled
827
                    if nodeId = true
3✔
828
                        newHID = true ' Block navigation (exit loop)
1✔
829
                    else
×
830
                        newHID = "" ' Continue bubbling
×
831
                    end if
832
                else
833
                    ' String nodeId - try to resolve target
3✔
834
                    if nodeId <> ""
3✔
835
                        otherGroup = m.groupStack.getByNodeId(nodeId)
1✔
836
                        if otherGroup <> invalid
3✔
837
                            newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
1✔
838
                        end if
839
                    end if
840
                end if
841

842
                ancestorIndex++
1✔
843
            end while
844

845
            return newHID
1✔
846
        end function
847

848
        ' * KEY EVENT HANDLER
849
        function onKeyEventHandler(key as string, press as boolean) as object
850
            ' Check long-press
851
            if m.enableLongPressFeature = true
3✔
852
                m.checkLongPressState(key, press)
1✔
853
            end if
854
            ' Prevent any navigation if it is disabled
855
            #if debug
4✔
856
                if m.enableFocusNavigation = false and press = true then print "[PLUGIN][FOCUS][INFO] Focus navigation is disabled. Call enableFocusNavigation(true) to make it enabled"
2✔
857
            #end if
858
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
2✔
859
            ' Execute action according to key press
860
            return m.executeNavigationAction(key, press)
1✔
861
        end function
862

863
        function executeNavigationAction(key as string, press as boolean) as object
864

865
            if true = press
3✔
866

867
                if -1 < Rotor.Utils.findInArray([
3✔
868
                        Rotor.Const.Direction.UP,
869
                        Rotor.Const.Direction.RIGHT,
870
                        Rotor.Const.Direction.DOWN,
871
                        Rotor.Const.Direction.LEFT,
872
                        Rotor.Const.Direction.BACK
873
                    ], key)
874

875
                    newHID = ""
1✔
876
                    direction = key
1✔
877

878
                    ' (1) Pick up current focused item
879

880
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
881

882
                    if focused = invalid
2✔
883
                        #if debug
×
884
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue detected. Last known focus id:\"${m.globalFocusHID}\". Please ensure valid focus.`
×
885
                        #end if
886
                        return m.parseOnKeyEventResult(key, false, false)
×
887
                    end if
888

889

890
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
891
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
892

893
                    if ancestorGroupsCount = 0
2✔
894
                        allFocusItems = m.focusItemStack.getAll()
×
895
                        possibleFocusItems = allFocusItems.keys()
×
896
                        parentGroupHID = ""
×
897
                    else
3✔
898
                        parentGroupHID = ancestorGroups[0]
1✔
899
                        group = m.groupStack.get(parentGroupHID)
1✔
900
                        possibleFocusItems = group.getGroupMembersHIDs()
1✔
901
                    end if
902

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

906
                    if Rotor.Utils.isBoolean(nodeId) and nodeId = true
2✔
907
                        ' It means that focus is handled, and no need further action by plugin.
908
                        return m.parseOnKeyEventResult(key, true, false)
×
909
                    end if
910

911
                    if nodeId <> ""
2✔
912
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
913
                    end if
914

915
                    if newHID = ""
3✔
916
                        ' (3) Try spatial navigation in direction, among possible focusItems
917
                        ' all = m.focusItemStack.getAll()
918
                        ' allKeys = all.Keys()
919
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
1✔
920
                    end if
921

922
                    ' (4) Check if found group. FocusItem can not point out of group.
923
                    if newHID = "" and ancestorGroupsCount > 0 ' (5/2) If this focused has parent group, lets try bubbling focus on ancestors (groups)
2✔
924
                        newHID = m.bubblingFocus(parentGroupHID, direction)
1✔
925
                        if Rotor.Utils.isBoolean(newHID)
2✔
926
                            if newHID = true
3✔
927
                                ' It means that focus is handled, and no need further action by plugin.
928
                                return m.parseOnKeyEventResult(key, true, false)
1✔
929
                            else
×
930
                                newHID = ""
×
931
                            end if
932
                        end if
933
                    end if
934

935
                    handled = m.setFocus(newHID)
1✔
936
                    return m.parseOnKeyEventResult(key, handled, false)
1✔
937

938
                else if key = "OK"
×
939

940
                    return m.parseOnKeyEventResult(key, true, true)
×
941

942
                end if
943
            end if
944

945
            return m.parseOnKeyEventResult(key, false, false)
×
946

947
        end function
948

949
        function parseOnKeyEventResult(key as string, handled as boolean, isSelected as boolean) as object
950
            result = {
1✔
951
                handled: handled,
952
                key: key
953
            }
954
            if m.globalFocusHID <> "" and handled = true
3✔
955
                focusItem = m.focusItemStack.get(m.globalFocusHID)
1✔
956
                widget = m.widgetTree.get(focusItem.HID)
1✔
957
                ' viewModelState = Rotor.Utils.deepCopy(widget.viewModelState)
958
                result.widget = widget
1✔
959
                if isSelected
2✔
960
                    result.isSelected = isSelected
×
961
                    focusItem.callOnSelectedFnOnWidget()
×
962
                end if
963
            end if
964
            return result
1✔
965
        end function
966

967
        sub checkLongPressState(key as string, press as boolean)
968
            m.longPressTimer.control = "stop"
1✔
969
            if press = true
3✔
970
                if m.isLongPress = false
3✔
971
                    m.longPressKey = key
1✔
972
                    m.longPressTimer.control = "start"
1✔
973
                end if
974
            else
×
975
                wasLongPress = m.isLongPress = true
×
976
                lastKey = m.longPressKey
×
977
                m.isLongPress = false
×
978
                m.longPressKey = ""
×
979
                if wasLongPress
×
980
                    m.delegateLongPressChanged(false, lastKey)
×
981
                end if
982
            end if
983
        end sub
984

985
        function proceedLongPress() as object
986
            return m.executeNavigationAction(m.longPressKey, true)
×
987
        end function
988

989
        ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
990
        function collectSegments(focused as object, direction as string, focusItemsHIDlist as object) as object
991
            focused.refreshBounding()
1✔
992

993
            refSegmentTop = focused.metrics.segments[Rotor.Const.Segment.TOP]
1✔
994
            refSegmentRight = focused.metrics.segments[Rotor.Const.Segment.RIGHT]
1✔
995
            refSegmentLeft = focused.metrics.segments[Rotor.Const.Segment.LEFT]
1✔
996
            refSegmentBottom = focused.metrics.segments[Rotor.Const.Segment.BOTTOM]
1✔
997
            referencePoint = { x: (refSegmentTop.x1 + refSegmentRight.x2) / 2, y: (refSegmentTop.y1 + refSegmentRight.y2) / 2 }
1✔
998

999
            validators = {
1✔
1000

1001
                "left": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
1002
                    right = segments[Rotor.Const.Segment.RIGHT]
1003
                    ' Candidate's right edge must be strictly left of focused element's left edge
1004
                    return right.x2 <= refSegmentLeft.x1 ? { isValid: true, segment: right } : { isValid: false }
1005
                end function,
1006

1007
                "up": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
1008
                    bottom = segments[Rotor.Const.Segment.BOTTOM]
1009
                    ' Candidate's bottom edge must be strictly above focused element's top edge
1010
                    return bottom.y2 <= refSegmentTop.y1 ? { isValid: true, segment: bottom } : { isValid: false }
1011
                end function,
1012

1013
                "right": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
1014
                    left = segments[Rotor.Const.Segment.LEFT]
1015
                    ' Candidate's left edge must be strictly right of focused element's right edge
1016
                    return left.x1 >= refSegmentRight.x2 ? { isValid: true, segment: left } : { isValid: false }
1017
                end function,
1018

1019
                "down": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
1020
                    top = segments[Rotor.Const.Segment.TOP]
1021
                    ' Candidate's top edge must be strictly below focused element's bottom edge
1022
                    return top.y1 >= refSegmentBottom.y2 ? { isValid: true, segment: top } : { isValid: false }
1023
                end function
1024
            }
1025
            segments = {}
1✔
1026
            validator = validators[direction]
1✔
1027
            for each HID in focusItemsHIDlist
1✔
1028
                if HID <> focused.HID
3✔
1029
                    focusItem = m.focusItemStack.get(HID)
1✔
1030
                    focusItem.refreshBounding()
1✔
1031
                    ' Pass appropriate reference segments based on direction
1032
                    if direction = "left" or direction = "right"
3✔
1033
                        result = validator(referencePoint, focusItem.metrics.segments, refSegmentLeft, refSegmentRight)
1✔
1034
                    else ' up or down
3✔
1035
                        result = validator(referencePoint, focusItem.metrics.segments, refSegmentTop, refSegmentBottom)
1✔
1036
                    end if
1037
                    if result.isValid
3✔
1038
                        segments[HID] = result.segment
1✔
1039
                    end if
1040
                end if
1041
            end for
1042

1043
            return segments
1✔
1044
        end function
1045

1046
        sub destroy()
1047
            ' Remove all groups
1048
            for each HID in m.groupStack.getAll()
1✔
1049
                m.groupStack.remove(HID)
1✔
1050
            end for
1051
            ' Remove all focus items
1052
            for each HID in m.focusItemStack.getAll()
1✔
1053
                m.focusItemStack.remove(HID)
1✔
1054
            end for
1055
            m.longPressTimer.unobserveFieldScoped("fire")
1✔
1056
            m.longPressTimer = invalid
1✔
1057
            m.widgetTree = invalid
1✔
1058
        end sub
1059

1060
    end class
1061

1062
    namespace FocusPluginHelper
1063

1064
        class BaseEntryStack extends Rotor.BaseStack
1065

1066
            function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
1067
                if ancestorHID <> "0"
3✔
1068
                    filteredStack = {}
1✔
1069
                    for each HID in m.stack
1✔
1070
                        if Rotor.Utils.isDescendantHID(HID, ancestorHID)
3✔
1071
                            filteredStack[HID] = m.get(HID)
1✔
1072
                        end if
1073
                    end for
1074
                else
3✔
1075
                    filteredStack = m.stack
1✔
1076
                end if
1077
                HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
1✔
1078
                return HID <> "" ? m.get(HID) : invalid
1✔
1079
            end function
1080

1081
            override sub remove(HID as string)
1082
                item = m.get(HID)
1✔
1083
                item.destroy()
1✔
1084
                super.remove(HID)
1✔
1085
            end sub
1086

1087
        end class
1088

1089
        class GroupStack extends BaseEntryStack
1090

1091
            function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1092
                foundHID = ""
×
1093
                for each HID in possibleGroups
×
1094
                    group = m.get(HID)
×
1095
                    if group.id = nodeId
×
1096
                        foundHID = group.HID
×
1097
                        exit for
1098
                    end if
1099
                end for
1100
                return foundHID
×
1101
            end function
1102

1103
        end class
1104

1105

1106
        class FocusItemStack extends BaseEntryStack
1107

1108
            function convertNodeIdToHID(nodeId as string, possibleFocusItems as object) as string
1109
                foundHID = ""
×
1110
                for each HID in possibleFocusItems
×
1111
                    focusItem = m.get(HID)
×
1112
                    if focusItem.id = nodeId
×
1113
                        foundHID = focusItem.HID
×
1114
                        exit for
1115
                    end if
1116
                end for
1117
                return foundHID
×
1118
            end function
1119

1120
            function hasEnabled(HID as string) as boolean
1121
                if m.has(HID)
×
1122
                    focusItem = m.get(HID)
×
1123
                    return focusItem.isEnabled
×
1124
                else
×
1125
                    return false
×
1126
                end if
1127
            end function
1128

1129
        end class
1130

1131
        class BaseFocusConfig
1132

1133
            autoSetIsFocusedOnContext as boolean
1134
            staticDirection as object
1135

1136
            sub new (config as object)
1137

1138
                m.HID = config.HID
1✔
1139
                m.id = config.id
1✔
1140

1141
                m.widget = config.widget
1✔
1142
                m.node = m.widget.node
1✔
1143
                m.isFocused = config.isFocused ?? false
1✔
1144

1145
                m.autoSetIsFocusedOnContext = config.autoSetIsFocusedOnContext ?? true
1✔
1146

1147
                m.isEnabled = config.isEnabled ?? true
1✔
1148
                m.staticDirection = {}
1✔
1149
                m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1✔
1150
                m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
1✔
1151
                m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
1✔
1152
                m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
1✔
1153
                m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
1✔
1154

1155
                m.onFocusChanged = config.onFocusChanged
1✔
1156
                m.longPressHandler = config.longPressHandler
1✔
1157
                m.onFocus = config.onFocus
1✔
1158

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

1161
                ' convenience (usually this is used on viewModelState)
1162
                if false = m.widget.viewModelState.DoesExist("isFocused") and true = m.autoSetIsFocusedOnContext
2✔
1163
                    m.widget.viewModelState.isFocused = false ' as default
1✔
1164
                end if
1165

1166
            end sub
1167

1168

1169
            HID as string
1170
            id as string
1171
            idByKeys as object
1172
            isEnabled as boolean
1173
            isFocused as boolean
1174
            onFocusChanged as dynamic
1175
            onFocus as dynamic
1176
            longPressHandler as dynamic
1177
            node as object
1178
            widget as object
1179

1180
            function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1181
                direction = m.staticDirection[direction]
1✔
1182
                if Rotor.Utils.isFunction(direction)
2✔
1183
                    return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
×
1184
                else
3✔
1185
                    return direction ?? ""
1✔
1186
                end if
1187
            end function
1188

1189
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1190
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1191
                if true = isFocused
3✔
1192
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1193
                end if
1194
            end sub
1195

1196
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1197
                if Rotor.Utils.isFunction(m.longPressHandler)
2✔
1198
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1199
                else
3✔
1200
                    return false
1✔
1201
                end if
1202
            end function
1203

1204
            sub destroy()
1205
                m.widget = invalid
1✔
1206
                m.node = invalid
1✔
1207
                m.onFocusChanged = invalid
1✔
1208
                m.longPressHandler = invalid
1✔
1209
            end sub
1210

1211
        end class
1212

1213
        class GroupClass extends BaseFocusConfig
1214
            ' Note: Spatial navigation is supported within group, there is no spatial navigation between groups
1215
            ' If you want to focus out to another group, you need to config a direction prop.
1216
            ' You can set a groupId or any focusItem widgetId.
1217
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1218
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1219

1220
            sub new (config as object)
1221
                super(config)
1✔
1222
                m.defaultFocusId = config.defaultFocusId ?? ""
1✔
1223
                m.lastFocusedHID = config.lastFocusedHID ?? ""
1✔
1224
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
1✔
1225
            end sub
1226

1227
            defaultFocusId as string
1228
            lastFocusedHID as string
1229
            enableSpatialEnter as boolean
1230
            focusItemsRef as object
1231
            groupsRef as object
1232

1233
            isFocusItem = false
1234
            isGroup = true
1235

1236
            sub setLastFocusedHID(lastFocusedHID as string)
1237
                m.lastFocusedHID = lastFocusedHID
1✔
1238
            end sub
1239

1240
            function getGroupMembersHIDs()
1241
                ' Collect all focusItems that are descendants of this group
1242
                ' Exclude items that belong to nested sub-groups
1243
                focusItems = m.focusItemsRef.getAll()
1✔
1244
                groups = m.groupsRef.getAll()
1✔
1245
                HIDlen = Len(m.HID)
1✔
1246
                collection = []
1✔
1247
                groupsKeys = groups.keys()
1✔
1248
                groupsCount = groups.Count()
1✔
1249

1250
                for each focusItemHID in focusItems
1✔
1251
                    ' Check if focusItem is a descendant of this group
1252
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
1✔
1253
                    if isDescendant
2✔
1254
                        ' Check if focusItem belongs to a nested sub-group
1255
                        shouldExclude = false
1✔
1256
                        otherGroupIndex = 0
1✔
1257
                        while shouldExclude = false and otherGroupIndex < groupsCount
1✔
1258
                            otherGroupHID = groupsKeys[otherGroupIndex]
1✔
1259
                            otherGroupHIDlen = Len(otherGroupHID)
1✔
1260
                            ' Exclude if belongs to deeper nested group
1261
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
1✔
1262
                            otherGroupIndex++
1✔
1263
                        end while
1264

1265
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1266
                    end if
1267
                end for
1268

1269
                return collection
1✔
1270
            end function
1271

1272
            '
1273
            ' getFallbackNodeId - Returns the nodeId to use for fallback (defaultFocusId or lastFocusedHID)
1274
            '
1275
            ' @returns {string} The nodeId to use for fallback, or empty string if none
1276
            '
1277
            function getFallbackNodeId() as string
1278
                if m.lastFocusedHID <> ""
2✔
1279
                    ' Note: lastFocusedHID is already a HID, not a nodeId, so we need to get the nodeId
1280
                    lastFocusedItem = m.focusItemsRef.get(m.lastFocusedHID)
×
1281
                    if lastFocusedItem <> invalid
×
1282
                        return lastFocusedItem.id
×
1283
                    end if
1284
                end if
1285

1286
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1287
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1288
                else
3✔
1289
                    return m.defaultFocusId
1✔
1290
                end if
1291
            end function
1292

1293
            function getFallbackIdentifier() as string
1294
                HID = ""
1✔
1295
                if m.lastFocusedHID <> ""
2✔
1296
                    return m.lastFocusedHID
×
1297
                else
3✔
1298
                    if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1299
                        defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1300
                    else
3✔
1301
                        defaultFocusId = m.defaultFocusId
1✔
1302
                    end if
1303

1304
                    if defaultFocusId <> ""
3✔
1305
                        focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1306
                        if focusItemsHIDlist.Count() > 0
3✔
1307

1308
                            ' Try find valid HID in focusItems by node id
1309
                            focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1✔
1310
                            if focusItemHID <> ""
3✔
1311
                                HID = focusItemHID
1✔
1312
                            end if
1313

1314
                        else
1315

3✔
1316
                            return defaultFocusId
1✔
1317

1318
                        end if
1319
                    end if
1320

1321
                end if
1322

1323
                return HID
1✔
1324
            end function
1325

1326
            function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1327
                HID = ""
1✔
1328
                for each HID in focusItemsHIDlist
1✔
1329
                    focusItem = m.focusItemsRef.get(HID)
1✔
1330
                    if focusItem <> invalid and focusItem.id = nodeId
3✔
1331
                        HID = focusItem.HID
1✔
1332
                        exit for
1333
                    end if
1334
                end for
1335
                return HID
1✔
1336
            end function
1337

1338
            sub applyFocus(isFocused as boolean)
1339
                if m.isFocused = isFocused then return
2✔
1340

1341
                m.isFocused = isFocused
1✔
1342

1343
                if m.autoSetIsFocusedOnContext
3✔
1344
                    m.widget.viewModelState.isInFocusChain = isFocused
1✔
1345
                end if
1346
                m.node.setField("isFocused", isFocused)
1✔
1347
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1348
            end sub
1349

1350
            override sub destroy()
1351
                super.destroy()
1✔
1352
                m.focusItemsRef = invalid
1✔
1353
                m.groupsRef = invalid
1✔
1354
            end sub
1355

1356

1357

1358
        end class
1359

1360
        class FocusItemClass extends BaseFocusConfig
1361

1362
            sub new (config as object)
1363
                super(config)
1✔
1364

1365
                m.onSelected = config.onSelected ?? ""
1✔
1366
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
1✔
1367
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1368
            end sub
1369

1370
            ' You can set a groupId or any focusItem widgetId.
1371
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1372
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1373

1374
            ' key as string
1375
            isFocusItem = true
1376
            isGroup = false
1377
            enableNativeFocus as boolean
1378
            enableSpatialNavigation as boolean
1379
            onSelected as dynamic
1380

1381
            private metrics = {
1382
                segments: {}
1383
            }
1384
            private bounding as object
1385

1386

1387
            sub refreshBounding()
1388
                b = m.node.sceneBoundingRect()
1✔
1389
                rotation = m.node.rotation
1✔
1390

1391
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1392
                ' That is why we can use translation without knowing the value of inheritParentTransform
1393
                ' If bounding x or y are not zero, then bounding will include the node's translation
1394
                if rotation = 0
3✔
1395
                    if b.y = 0 and b.x = 0
2✔
1396
                        t = m.node.translation
×
1397
                        b.x += t[0]
×
1398
                        b.y += t[1]
×
1399
                    end if
1400

1401
                    m.metrics.append(b)
1✔
1402
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
1✔
1403
                        x1: b.x, y1: b.y,
1404
                        x2: b.x, y2: b.y + b.height
1405
                    }
1406
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
1✔
1407
                        x1: b.x, y1: b.y,
1408
                        x2: b.x + b.width, y2: b.y
1409
                    }
1410
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
1✔
1411
                        x1: b.x + b.width, y1: b.y,
1412
                        x2: b.x + b.width, y2: b.y + b.height
1413
                    }
1414
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
1✔
1415
                        x1: b.x, y1: b.y + b.height,
1416
                        x2: b.x + b.width, y2: b.y + b.height
1417
                    }
1418
                    m.metrics.middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
1✔
1419
                else
×
1420
                    scaleRotateCenter = m.node.scaleRotateCenter
×
1421
                    dims = m.node.localBoundingRect() ' We need this to get proper (rotated value of rotated x and y)
×
1422
                    if b.y = 0 and b.x = 0
×
1423
                        t = m.node.translation
×
1424
                        b.x += t[0]
×
1425
                        b.y += t[1]
×
1426
                    end if
1427
                    b.width = dims.width
×
1428
                    b.height = dims.height
×
1429
                    m.metrics.append(b)
×
1430

1431
                    ' Calculate rotated segments
1432
                    segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
×
1433
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1434
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
×
1435

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

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

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

1448
                    ' Calculate rotated middle point
1449
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1450
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1451
                    m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
×
1452

1453
                end if
1454
            end sub
1455

1456
            override sub destroy()
1457
                m.onSelected = invalid
1✔
1458
                m.metrics.segments.Clear()
1✔
1459
                super.destroy()
1✔
1460
            end sub
1461

1462
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1463
                if m.isFocused = isFocused then return
2✔
1464

1465
                m.isFocused = isFocused
1✔
1466

1467
                if m.autoSetIsFocusedOnContext
3✔
1468
                    m.widget.viewModelState.isFocused = isFocused
1✔
1469
                end if
1470

1471
                m.node.setField("isFocused", isFocused)
1✔
1472

1473
                if enableNativeFocus or m.enableNativeFocus
2✔
1474
                    m.node.setFocus(isFocused)
×
1475
                end if
1476

1477
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1478

1479
            end sub
1480

1481
            sub callOnSelectedFnOnWidget()
1482
                Rotor.Utils.callbackScoped(m.onSelected, m.widget)
×
1483
            end sub
1484

1485
        end class
1486

1487
        class ClosestSegmentToPointCalculatorClass
1488

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

1492
                A = x - x1
1✔
1493
                B = y - y1
1✔
1494
                C = x2 - x1
1✔
1495
                D = y2 - y1
1✔
1496

1497
                dot = A * C + B * D
1✔
1498
                len_sq = C * C + D * D
1✔
1499
                param = -1
1✔
1500
                if len_sq <> 0
3✔
1501
                    param = dot / len_sq
1✔
1502
                end if
1503

1504
                xx = 0
1✔
1505
                yy = 0
1✔
1506

1507
                if param < 0
2✔
1508
                    xx = x1
×
1509
                    yy = y1
×
1510
                else if param > 1
2✔
1511
                    xx = x2
×
1512
                    yy = y2
×
1513
                else
3✔
1514
                    xx = x1 + param * C
1✔
1515
                    yy = y1 + param * D
1✔
1516
                end if
1517

1518
                dx = x - xx
1✔
1519
                dy = y - yy
1✔
1520
                return dx * dx + dy * dy
1✔
1521
            end function
1522

1523
            function distToSegment(p as object, s1 as object, s2 as object)
1524
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1525
            end function
1526

1527
        end class
1528

1529
    end namespace
1530

1531
    namespace FocusPluginHelper
1532

1533
        sub longPressObserverCallback(msg)
1534
            extraInfo = msg.GetInfo()
1✔
1535

1536
            pluginKey = extraInfo["pluginKey"]
1✔
1537

1538
            globalScope = GetGlobalAA()
1✔
1539
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
1✔
1540
            plugin = frameworkInstance.plugins[pluginKey]
1✔
1541
            plugin.isLongPress = true
1✔
1542
            ' plugin.longPressStartHID = plugin.globalFocusHID
1543
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
1✔
1544

1545
        end sub
1546

1547
    end namespace
1548

1549
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