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

mobalazs / rotor-framework / 20966845266

13 Jan 2026 05:48PM UTC coverage: 86.364%. Remained the same
20966845266

push

github

mobalazs
fix(BaseWidget): HID  property for hierarchical unique ID should bne public

2033 of 2354 relevant lines covered (86.36%)

1.19 hits per line

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

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

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

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

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

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

377
        }
378

379
        ' Configuration
380
        longPressDuration = 0.4
381
        enableLongPressFeature = true
382
        enableFocusNavigation = true
383

384
        ' State tracking
385
        globalFocusHID = ""
386
        globalFocusId = ""
387
        isLongPress = false
388
        longPressKey = ""
389

390
        ' References
391
        widgetTree as object
392
        frameworkInstance as Rotor.Framework
393

394
        ' Helper objects
395
        focusItemStack = new Rotor.FocusPluginHelper.FocusItemStack()
396
        groupStack = new Rotor.FocusPluginHelper.GroupStack()
397
        distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass()
398
        longPressTimer = CreateObject("roSGNode", "Timer")
399

400
        ' ---------------------------------------------------------------------
401
        ' init - Initializes the plugin instance
402
        '
403
        ' Sets up internal state and helpers.
404
        '
405
        sub init ()
406
            m.widgetTree = m.frameworkInstance.builder.widgetTree ' Reference to the main widget tree
1✔
407
            m.longPressTimer.addField("pluginKey", "string", false)
1✔
408
            m.longPressTimer.setFields({
1✔
409
                "pluginKey": m.pluginKey,
410
                duration: m.longPressDuration
411
            })
412
            ' Observe timer fire event to handle long press callback
413
            m.longPressTimer.observeFieldScoped("fire", "Rotor_FocusPluginHelper_longPressObserverCallback", ["pluginKey"])
1✔
414
        end sub
415

416
        '
417
        ' storeGlobalFocusHID - Stores the globally focused widget's HID and ID
418
        '
419
        ' @param {string} HID - The Hierarchical ID of the focused widget
420
        ' @param {string} id - The regular ID of the focused widget
421
        '
422
        sub storeGlobalFocusHID(HID as string, id as string)
423
            ' Store focus reference within the plugin
424
            m.globalFocusHID = HID
1✔
425
            m.globalFocusId = id
1✔
426
        end sub
427

428
        '
429
        ' getFocusedWidget - Gets the widget instance that currently holds global focus
430
        '
431
        ' @returns {object} The focused widget object, or invalid if none
432
        '
433
        function getFocusedWidget() as object
434
            return m.getFocusedItem()?.widget
1✔
435
        end function
436

437
        '
438
        ' getFocusedItem - Gets the FocusItem instance corresponding to the globally focused widget
439
        '
440
        ' @returns {object} The FocusItem instance, or invalid if none
441
        '
442
        function getFocusedItem() as object
443
            return m.focusItemStack.get(m.globalFocusHID)
1✔
444
        end function
445

446
        '
447
        ' setFocusConfig - Configures focus properties (FocusItem and/or Group) for a widget
448
        '
449
        ' @param {object} widget - The widget to configure
450
        ' @param {object} pluginConfig - The focus configuration object from the widget's spec
451
        '
452
        sub setFocusConfig(widget as object, pluginConfig as object)
453

454
            if pluginConfig = invalid then return ' No config provided
2✔
455
            HID = widget.HID
1✔
456
            id = widget.id
1✔
457

458
            ' Make a copy to avoid modifying the original config
459
            config = Rotor.Utils.deepCopy(pluginConfig)
1✔
460

461
            ' Ensure essential identifiers are in the config
462
            config.id = id
1✔
463
            config.HID = widget.HID
1✔
464

465
            ' Handle group configuration if present
466
            if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY)
3✔
467
                ' Handle focus item configuration if applicable
468
                m.setupFocusItem(HID, config, widget)
1✔
469
            else
470
                ' Handle group configuration
3✔
471
                m.setupGroup(HID, config, widget)
1✔
472
            end if
473
        end sub
474

475
        '
476
        ' setupGroup - Creates and registers a new Focus Group based on configuration
477
        '
478
        ' @param {string} HID - The Hierarchical ID of the widget acting as the group root
479
        ' @param {object} config - The full focus configuration for the widget
480
        ' @param {object} widget - The widget instance itself
481
        '
482
        sub setupGroup(HID as string, config as object, widget as object)
483
            ' Copy essential info to the group-specific config
484
            config.id = config.id
1✔
485
            config.HID = config.HID
1✔
486
            config.widget = widget
1✔
487
            ' Create and configure the Group instance
488
            newGroup = new Rotor.FocusPluginHelper.GroupClass(config)
1✔
489
            newGroup.focusItemsRef = m.focusItemStack ' Provide reference to focus items
1✔
490
            newGroup.groupsRef = m.groupStack ' Provide reference to other groups
1✔
491
            m.groupStack.set(config.HID, newGroup) ' Register the new group
1✔
492
        end sub
493

494
        '
495
        ' setupFocusItem - Creates and registers a new Focus Item based on configuration
496
        '
497
        ' @param {string} HID - The Hierarchical ID of the focusItem widget
498
        ' @param {object} config - The full focus configuration for the widget
499
        ' @param {object} widget - The widget instance itself
500
        '
501
        sub setupFocusItem(HID as string, config as object, widget as object)
502
            config.widget = widget ' Ensure widget reference is in the config
1✔
503

504
            ' Create and register the FocusItem instance
505
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
1✔
506
            m.focusItemStack.set(HID, newFocusItem)
1✔
507
        end sub
508

509
        '
510
        ' findAncestorGroups - Finds all ancestor groups for a given widget HID
511
        '
512
        ' @param {string} HID - The Hierarchical ID of the widget
513
        ' @returns {object} An roArray of ancestor group HIDs, sorted with the immediate parent first (descending HID length)
514
        '
515
        function findAncestorGroups(HID as string) as object
516
            allGroups = m.groupStack.getAll() ' Get all registered groups
1✔
517
            ancestorGroups = []
1✔
518
            ' Iterate through all groups to find ancestors
519
            for each groupHID in allGroups
1✔
520
                if Rotor.Utils.isAncestorHID(groupHID, HID)
2✔
521
                    ancestorGroups.push(groupHID)
1✔
522
                end if
523
            end for
524
            ' Sort by HID length descending (parent first)
525
            ancestorGroups.Sort("r")
1✔
526

527
            ' Note:
528
            ' - Parent group is at index 0
529
            ' - If HID is a focusItem, its direct parent group is included
530
            ' - If HID is a group, the group itself is NOT included
531
            return ancestorGroups
1✔
532
        end function
533

534
        '
535
        ' removeFocusConfig - Removes focus configuration (Group and/or FocusItem) for a widget
536
        '
537
        ' @param {string} HID - The Hierarchical ID of the widget whose config should be removed
538
        '
539
        sub removeFocusConfig(HID as string)
540
            ' Remove associated group, if it exists
541
            if m.groupStack.has(HID)
2✔
542
                m.groupStack.remove(HID)
1✔
543
            end if
544
            ' Remove associated focus item, if it exists
545
            if m.focusItemStack.has(HID)
2✔
546
                m.focusItemStack.remove(HID)
1✔
547
            end if
548
        end sub
549

550
        '
551
        ' setFocus - Sets or removes focus from a specific widget or group
552
        '
553
        ' Handles focus state changes, callbacks, and native focus interaction.
554
        '
555
        ' @param {dynamic} ref - The target: HID (string) of a FocusItem or Group, or Node ID (string) of a Group
556
        ' @param {boolean} isFocused - True to set focus, false to remove focus (default: true)
557
        ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node (default: false)
558
        ' @returns {boolean} True if the focus state was successfully changed, false otherwise
559
        '
560
        function setFocus(ref as dynamic, isFocused = true as boolean, enableNativeFocus = false as boolean) as boolean
561

562
            ' Resolve reference (HID or ID) to a focusItem item.
563
            focusItem = invalid ' Initialize target focus item
1✔
564

565
            ' Exit if reference is empty or invalid.
566
            if ref = invalid or ref = "" then return false
2✔
567

568
            if m.focusItemStack.has(ref)
2✔
569
                ' Case 1: ref is a valid focusItem HID.
570
                focusItem = m.focusItemStack.get(ref)
1✔
571
            else
572
                ' Case 2: ref might be a focusItem node ID.
3✔
573
                focusItem = m.focusItemStack.getByNodeId(ref)
1✔
574

575
                if focusItem = invalid
3✔
576
                    ' Case 3: ref might be a group HID or group node ID.
577
                    ' Try finding group by HID first, then by Node ID.
578
                    group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
1✔
579
                    if group <> invalid
3✔
580
                        ' If group found, find its default/entry focus item recursively.
581
                        HID = m.capturingFocus_recursively(group.HID)
1✔
582
                        focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
1✔
583
                        ' else: ref is not a known FocusItem HID or Group identifier
584
                    end if
585
                end if
586
            end if
587

588
            ' Handle case where the target focus item could not be found or resolved.
589
            if focusItem = invalid
2✔
590
                focused = m.focusItemStack.get(m.globalFocusHID) ' Check current focus
1✔
591
                #if debug
4✔
592
                    ' Log warnings if focus target is not found
593
                    if focused = invalid
2✔
594
                        print `[PLUGIN][FOCUS][WARNING] Requested focus target ref: "${ref}" was not found or resolved to a valid FocusItem.`
×
595
                        if m.globalFocusHID = ""
×
596
                            ' If global focus is also lost, indicate potential issue.
597
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue likely. No current focus set. Ensure valid initial focus.`
×
598
                        else
×
599
                            print `[PLUGIN][FOCUS][WARNING] Current focus HID: "${m.globalFocusHID}". Ensure target "${ref}" is registered and reachable.`
×
600
                        end if
601
                    else
3✔
602
                        print `[PLUGIN][FOCUS][WARNING] Could not find focus target ref: "${ref}". Current focus remains on HID: "${m.globalFocusHID}", id"${m.globalFocusId}"".`
1✔
603
                    end if
604
                #end if
605
                return false ' Indicate focus change failed
1✔
606
            end if
607

608
            ' Found a valid focusItem to target
609
            HID = focusItem.HID
1✔
610

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

615
            ' Cannot focus an invisible item.
616
            if focusItem.node.visible = false and isFocused = true then return false
2✔
617

618
            ' Determine if native focus should be enabled (request or item default)
619
            enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
1✔
620

621
            ' Prevent focusing a disabled item.
622
            preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
1✔
623
            if preventFocusOnDisabled
2✔
624
                return false ' Indicate focus change failed
×
625
            end if
626

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

630
            lastFocusChainingGroups = []
1✔
631

632
            ' Handle blurring the previously focused item
633
            if m.globalFocusHID <> "" ' If something was focused before
2✔
634
                lastFocused = m.focusItemStack.get(m.globalFocusHID)
1✔
635
                if lastFocused <> invalid ' Check if the last focused widget hasn't been destroyed
3✔
636
                    ' Record the last focused item within its parent group for potential future use (e.g., returning focus)
637
                    lastFocusChainingGroups = m.findAncestorGroups(m.globalFocusHID)
1✔
638
                    if lastFocusChainingGroups.Count() > 0
3✔
639
                        parentGroupHID = lastFocusChainingGroups[0]
1✔
640
                        if parentGroupHID <> invalid and parentGroupHID <> ""
3✔
641
                            group = m.groupStack.get(parentGroupHID)
1✔
642
                            if group <> invalid
3✔
643
                                group.setLastFocusedHID(m.globalFocusHID)
1✔
644
                            end if
645
                        end if
646
                    end if
647
                end if
648
            end if
649

650
            ' Prepare notification list: all affected groups (unique)
651
            allAffectedGroups = []
1✔
652
            for each groupHID in focusChainGroups
1✔
653
                allAffectedGroups.unshift(groupHID) ' Add in reverse order (highest ancestor first)
1✔
654
            end for
655
            for each groupHID in lastFocusChainingGroups
1✔
656
                if -1 = Rotor.Utils.findInArray(allAffectedGroups, groupHID)
2✔
657
                    allAffectedGroups.unshift(groupHID) ' Add in reverse order if not already present
1✔
658
                end if
659
            end for
660

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

664
            ' Blur the previously focused item (after notification)
665
            if m.globalFocusHID <> "" and lastFocused <> invalid
2✔
666
                lastFocused.applyFocus(false, enableNativeFocus)
1✔
667
            end if
668

669
            ' Apply focus state (focused/blurred) to the target item.
670
            focusItem.applyFocus(isFocused, enableNativeFocus)
1✔
671

672
            ' Update the globally tracked focused item.
673
            m.storeGlobalFocusHID(isFocused ? HID : "", isFocused ? focusItem.id : "")
1✔
674

675
            ' Ensure SceneGraph root has focus if native focus wasn't explicitly enabled on the item.
676
            if enableNativeFocus = false
3✔
677
                globalScope = GetGlobalAA()
1✔
678
                if globalScope.top.isInFocusChain() = false
2✔
679
                    globalScope.top.setFocus(true)
1✔
680
                end if
681
            end if
682

683
            return true
1✔
684

685
        end function
686

687
        '
688
        ' notifyFocusAtAncestorGroups - Applies the correct focus state (in focus chain or not) to a list of group HIDs
689
        '
690
        ' @param {string} HID - The HID of the item that ultimately received/lost focus
691
        ' @param {object} groupHIDs - An roArray of group HIDs to notify
692
        '
693
        sub notifyFocusAtAncestorGroups(HID as string, groupHIDs = [] as object)
694

695
            ' Notify all ancestor groups
696
            if groupHIDs.Count() > 0
3✔
697
                for each groupHID in groupHIDs
1✔
698

699
                    group = m.groupStack.get(groupHID)
1✔
700
                    isInFocusChain = Rotor.Utils.isAncestorHID(groupHID, HID)
1✔
701
                    group.applyFocus(isInFocusChain)
1✔
702

703
                end for
704
            end if
705
        end sub
706

707
        sub notifyLongPressAtAncestorGroups(isLongPress as boolean, key as string, HID as string, groupHIDs = [] as object)
708
            ' Notify all ancestor groups
709
            if groupHIDs.Count() > 0
3✔
710
                for each groupHID in groupHIDs
1✔
711
                    group = m.groupStack.get(groupHID)
1✔
712
                    handled = group.callLongPressHandler(isLongPress, key)
1✔
713
                    if handled then exit for
2✔
714
                end for
715
            end if
716
        end sub
717

718
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
719
            focused = m.getFocusedItem()
1✔
720
            handled = focused.callLongPressHandler(isLongPress, key)
1✔
721
            if handled then return
2✔
722

723
            focusChainGroups = m.findAncestorGroups(focused.HID)
1✔
724
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
1✔
725
        end sub
726

727
        function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object) as string
728
            if focused.enableSpatialNavigation = false then return ""
2✔
729
            if direction = Rotor.Const.Direction.BACK then return ""
2✔
730

731
            ' Remove current focused item from candidates
732
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
1✔
733
            if index >= 0 then focusItemsHIDlist.delete(index)
1✔
734

735
            ' Find closest focusable item in direction
736
            segments = m.collectSegments(focused, direction, focusItemsHIDlist)
1✔
737
            if segments.Count() > 0
3✔
738
                return m.findClosestSegment(segments, focused.metrics.middlePoint)
1✔
739
            end if
740

741
            return ""
1✔
742
        end function
743

744
        function findClosestSegment(segments as object, middlePoint as object) as string
745
            distances = []
1✔
746

747
            ' Calculate distance from middle point to each segment
748
            for each HID in segments
1✔
749
                segment = segments[HID]
1✔
750
                distance = m.distanceCalculator.distToSegment(middlePoint, {
1✔
751
                    x: segment.x1,
752
                    y: segment.y1
753
                }, {
754
                    x: segment.x2,
755
                    y: segment.y2
756
                })
757

758
                distances.push({
1✔
759
                    HID: HID,
760
                    distance: distance
761
                })
762
            end for
763

764
            ' Find segment with minimum distance
765
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
1✔
766
                return a < b
767
            end function)
768

769
            return minDistItem.HID
1✔
770
        end function
771

772

773
        ' Waterfall of fallback's of groups (linked together with defaultFocusId)
774
        function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
775
            ' Resolve identifier to a group
776
            group = m.groupStack.get(identifier)
1✔
777
            if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
1✔
778
            if group = invalid then return ""
2✔
779

780
            ' Get fallback identifier for this group
781
            newHID = group.getFallbackIdentifier()
1✔
782

783
            ' Check if we found a FocusItem
784
            if m.focusItemStack.has(newHID)
3✔
785
                ' Apply spatial enter feature if enabled
786
                if group.enableSpatialEnter = true and direction <> ""
2✔
787
                    focused = m.focusItemStack.get(m.globalFocusHID)
×
788
                    newSpatialHID = m.spatialNavigation(focused, direction, group.getGroupMembersHIDs())
×
789
                    if newSpatialHID <> "" then newHID = newSpatialHID
×
790
                end if
791

792
            else if newHID <> ""
3✔
793
                ' Try to find as group first, then deep search
794
                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
1✔
795

796
                ' If still not found, perform deep search in all descendants
797
                if newHID = ""
2✔
798
                    newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
1✔
799
                end if
800
            end if
801

802
            ' Prevent capturing by fallback in the same group where original focus was
803
            if newHID <> "" and m.globalFocusHID <> ""
2✔
804
                currentAncestors = m.findAncestorGroups(m.globalFocusHID)
1✔
805
                newAncestors = m.findAncestorGroups(newHID)
1✔
806
                if currentAncestors.Count() > 0 and newAncestors.Count() > 0
3✔
807
                    if currentAncestors[0] = newAncestors[0] then newHID = ""
1✔
808
                end if
809
            end if
810

811
            return newHID
1✔
812
        end function
813

814
        '
815
        ' deepSearchFocusItemByNodeId - Deep search for a FocusItem or Group by nodeId within a group hierarchy
816
        '
817
        ' @param {string} groupHID - The HID of the group to search within
818
        ' @param {string} nodeId - The node ID to search for
819
        ' @returns {string} The HID of the found FocusItem or Group, or empty string if not found
820
        '
821
        function deepSearchFocusItemByNodeId(groupHID as string, nodeId as string) as string
822
            if nodeId = "" then return ""
2✔
823

824
            ' Get all descendants of this group (both FocusItems and nested Groups)
825
            allFocusItems = m.focusItemStack.getAll()
1✔
826
            allGroups = m.groupStack.getAll()
1✔
827

828
            ' First, search in direct and nested FocusItems
829
            for each focusItemHID in allFocusItems
1✔
830
                if Rotor.Utils.isDescendantHID(focusItemHID, groupHID)
3✔
831
                    focusItem = m.focusItemStack.get(focusItemHID)
1✔
832
                    if focusItem <> invalid and focusItem.id = nodeId
2✔
833
                        return focusItemHID
×
834
                    end if
835
                end if
836
            end for
837

838
            ' Second, search in nested Groups (and if found, apply fallback logic on that group)
839
            for each nestedGroupHID in allGroups
1✔
840
                if Rotor.Utils.isDescendantHID(nestedGroupHID, groupHID) and nestedGroupHID <> groupHID
3✔
841
                    nestedGroup = m.groupStack.get(nestedGroupHID)
1✔
842
                    if nestedGroup <> invalid and nestedGroup.id = nodeId
3✔
843
                        ' Found a matching group - now apply fallback logic on it
844
                        fallbackHID = nestedGroup.getFallbackIdentifier()
1✔
845
                        if m.focusItemStack.has(fallbackHID)
3✔
846
                            return fallbackHID
1✔
847
                        else if fallbackHID <> ""
×
848
                            ' Recursively resolve the fallback
849
                            return m.capturingFocus_recursively(fallbackHID, "", nestedGroupHID)
×
850
                        end if
851
                    end if
852
                end if
853
            end for
854

855
            return ""
×
856
        end function
857

858
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
859
            newHID = ""
1✔
860

861
            ' Build ancestor chain (current group + all ancestors)
862
            ancestorGroups = m.findAncestorGroups(groupHID)
1✔
863
            ancestorGroups.unshift(groupHID)
1✔
864
            ancestorGroupsCount = ancestorGroups.Count()
1✔
865
            ancestorIndex = 0
1✔
866

867
            ' Bubble up through ancestor groups until we find a target or reach the top
868
            while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
1✔
869
                ' Get next ancestor group
870
                groupHID = ancestorGroups[ancestorIndex]
1✔
871
                group = m.groupStack.get(groupHID)
1✔
872

873
                ' Check group's direction configuration
874
                nodeId = group.getStaticNodeIdInDirection(direction)
1✔
875

876
                if Rotor.Utils.isBoolean(nodeId)
2✔
877
                    ' Boolean means focus is explicitly handled
878
                    if nodeId = true
3✔
879
                        newHID = true ' Block navigation (exit loop)
1✔
880
                    else
×
881
                        newHID = "" ' Continue bubbling
×
882
                    end if
883
                else
884
                    ' String nodeId - try to resolve target
3✔
885
                    if nodeId <> ""
3✔
886
                        otherGroup = m.groupStack.getByNodeId(nodeId)
1✔
887
                        if otherGroup <> invalid
3✔
888
                            newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
1✔
889
                        end if
890
                    end if
891
                end if
892

893
                ancestorIndex++
1✔
894
            end while
895

896
            return newHID
1✔
897
        end function
898

899
        ' * KEY EVENT HANDLER
900
        function onKeyEventHandler(key as string, press as boolean) as object
901
            ' Check long-press
902
            if m.enableLongPressFeature = true
3✔
903
                m.checkLongPressState(key, press)
1✔
904
            end if
905
            ' Prevent any navigation if it is disabled
906
            #if debug
4✔
907
                if m.enableFocusNavigation = false and press = true then print "[PLUGIN][FOCUS][INFO] Focus navigation is disabled. Call enableFocusNavigation(true) to make it enabled"
2✔
908
            #end if
909
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
2✔
910
            ' Execute action according to key press
911
            return m.executeNavigationAction(key, press)
1✔
912
        end function
913

914
        function executeNavigationAction(key as string, press as boolean) as object
915

916
            if true = press
3✔
917

918
                if -1 < Rotor.Utils.findInArray([
2✔
919
                        Rotor.Const.Direction.UP,
920
                        Rotor.Const.Direction.RIGHT,
921
                        Rotor.Const.Direction.DOWN,
922
                        Rotor.Const.Direction.LEFT,
923
                        Rotor.Const.Direction.BACK
924
                    ], key)
925

926
                    newHID = ""
1✔
927
                    direction = key
1✔
928

929
                    ' (1) Pick up current focused item
930

931
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
932

933
                    if focused = invalid
2✔
934
                        #if debug
×
935
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue detected. Last known focus id:\"${m.globalFocusHID}\". Please ensure valid focus.`
×
936
                        #end if
937
                        return m.parseOnKeyEventResult(key, false, false)
×
938
                    end if
939

940

941
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
942
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
943

944
                    if ancestorGroupsCount = 0
2✔
945
                        allFocusItems = m.focusItemStack.getAll()
×
946
                        possibleFocusItems = allFocusItems.keys()
×
947
                        parentGroupHID = ""
×
948
                    else
3✔
949
                        parentGroupHID = ancestorGroups[0]
1✔
950
                        group = m.groupStack.get(parentGroupHID)
1✔
951
                        possibleFocusItems = group.getGroupMembersHIDs()
1✔
952
                    end if
953

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

957
                    if Rotor.Utils.isBoolean(nodeId) and nodeId = true
2✔
958
                        ' It means that focus is handled, and no need further action by plugin.
959
                        return m.parseOnKeyEventResult(key, true, false)
×
960
                    end if
961

962
                    if nodeId <> ""
2✔
963
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
964
                    end if
965

966
                    if newHID = ""
3✔
967
                        ' (3) Try spatial navigation in direction, among possible focusItems
968
                        ' all = m.focusItemStack.getAll()
969
                        ' allKeys = all.Keys()
970
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
1✔
971
                    end if
972

973
                    ' (4) Check if found group. FocusItem can not point out of group.
974
                    if newHID = "" and ancestorGroupsCount > 0 ' (5/2) If this focused has parent group, lets try bubbling focus on ancestors (groups)
2✔
975
                        newHID = m.bubblingFocus(parentGroupHID, direction)
1✔
976
                        if Rotor.Utils.isBoolean(newHID)
2✔
977
                            if newHID = true
3✔
978
                                ' It means that focus is handled, and no need further action by plugin.
979
                                return m.parseOnKeyEventResult(key, true, false)
1✔
980
                            else
×
981
                                newHID = ""
×
982
                            end if
983
                        end if
984
                    end if
985

986
                    handled = m.setFocus(newHID)
1✔
987
                    return m.parseOnKeyEventResult(key, handled, false)
1✔
988

989
                else if key = "OK"
3✔
990

991
                    return m.parseOnKeyEventResult(key, true, true)
1✔
992

993
                end if
994
            end if
995

996
            return m.parseOnKeyEventResult(key, false, false)
×
997

998
        end function
999

1000
        function parseOnKeyEventResult(key as string, handled as boolean, isSelected as boolean) as object
1001
            result = {
1✔
1002
                handled: handled,
1003
                key: key
1004
            }
1005
            if m.globalFocusHID <> "" and handled = true
3✔
1006
                focusItem = m.focusItemStack.get(m.globalFocusHID)
1✔
1007
                widget = m.widgetTree.get(focusItem.HID)
1✔
1008
                ' viewModelState = Rotor.Utils.deepCopy(widget.viewModelState)
1009
                result.widget = widget
1✔
1010
                if isSelected
3✔
1011
                    result.isSelected = isSelected
1✔
1012
                    focusItem.callOnSelectFnOnWidget()
1✔
1013
                end if
1014
            end if
1015
            return result
1✔
1016
        end function
1017

1018
        sub checkLongPressState(key as string, press as boolean)
1019
            m.longPressTimer.control = "stop"
1✔
1020
            if press = true
3✔
1021
                if m.isLongPress = false
3✔
1022
                    m.longPressKey = key
1✔
1023
                    m.longPressTimer.control = "start"
1✔
1024
                end if
1025
            else
×
1026
                wasLongPress = m.isLongPress = true
×
1027
                lastKey = m.longPressKey
×
1028
                m.isLongPress = false
×
1029
                m.longPressKey = ""
×
1030
                if wasLongPress
×
1031
                    m.delegateLongPressChanged(false, lastKey)
×
1032
                end if
1033
            end if
1034
        end sub
1035

1036
        function proceedLongPress() as object
1037
            return m.executeNavigationAction(m.longPressKey, true)
×
1038
        end function
1039

1040
        ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
1041
        function collectSegments(focused as object, direction as string, focusItemsHIDlist as object) as object
1042
            focused.refreshBounding()
1✔
1043

1044
            refSegmentTop = focused.metrics.segments[Rotor.Const.Segment.TOP]
1✔
1045
            refSegmentRight = focused.metrics.segments[Rotor.Const.Segment.RIGHT]
1✔
1046
            refSegmentLeft = focused.metrics.segments[Rotor.Const.Segment.LEFT]
1✔
1047
            refSegmentBottom = focused.metrics.segments[Rotor.Const.Segment.BOTTOM]
1✔
1048
            referencePoint = { x: (refSegmentTop.x1 + refSegmentRight.x2) / 2, y: (refSegmentTop.y1 + refSegmentRight.y2) / 2 }
1✔
1049

1050
            validators = {
1✔
1051

1052
                "left": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
1053
                    right = segments[Rotor.Const.Segment.RIGHT]
1054
                    ' Candidate's right edge must be strictly left of focused element's left edge
1055
                    return right.x2 <= refSegmentLeft.x1 ? { isValid: true, segment: right } : { isValid: false }
1056
                end function,
1057

1058
                "up": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
1059
                    bottom = segments[Rotor.Const.Segment.BOTTOM]
1060
                    ' Candidate's bottom edge must be strictly above focused element's top edge
1061
                    return bottom.y2 <= refSegmentTop.y1 ? { isValid: true, segment: bottom } : { isValid: false }
1062
                end function,
1063

1064
                "right": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
1065
                    left = segments[Rotor.Const.Segment.LEFT]
1066
                    ' Candidate's left edge must be strictly right of focused element's right edge
1067
                    return left.x1 >= refSegmentRight.x2 ? { isValid: true, segment: left } : { isValid: false }
1068
                end function,
1069

1070
                "down": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
1071
                    top = segments[Rotor.Const.Segment.TOP]
1072
                    ' Candidate's top edge must be strictly below focused element's bottom edge
1073
                    return top.y1 >= refSegmentBottom.y2 ? { isValid: true, segment: top } : { isValid: false }
1074
                end function
1075
            }
1076
            segments = {}
1✔
1077
            validator = validators[direction]
1✔
1078
            for each HID in focusItemsHIDlist
1✔
1079
                if HID <> focused.HID
3✔
1080
                    focusItem = m.focusItemStack.get(HID)
1✔
1081
                    focusItem.refreshBounding()
1✔
1082
                    ' Pass appropriate reference segments based on direction
1083
                    if direction = "left" or direction = "right"
3✔
1084
                        result = validator(referencePoint, focusItem.metrics.segments, refSegmentLeft, refSegmentRight)
1✔
1085
                    else ' up or down
3✔
1086
                        result = validator(referencePoint, focusItem.metrics.segments, refSegmentTop, refSegmentBottom)
1✔
1087
                    end if
1088
                    if result.isValid
3✔
1089
                        segments[HID] = result.segment
1✔
1090
                    end if
1091
                end if
1092
            end for
1093

1094
            return segments
1✔
1095
        end function
1096

1097
        sub destroy()
1098
            ' Remove all groups
1099
            for each HID in m.groupStack.getAll()
1✔
1100
                m.groupStack.remove(HID)
1✔
1101
            end for
1102
            ' Remove all focus items
1103
            for each HID in m.focusItemStack.getAll()
1✔
1104
                m.focusItemStack.remove(HID)
1✔
1105
            end for
1106
            m.longPressTimer.unobserveFieldScoped("fire")
1✔
1107
            m.longPressTimer = invalid
1✔
1108
            m.widgetTree = invalid
1✔
1109
        end sub
1110

1111
    end class
1112

1113
    namespace FocusPluginHelper
1114

1115
        class BaseEntryStack extends Rotor.BaseStack
1116

1117
            function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
1118
                if ancestorHID <> "0"
3✔
1119
                    filteredStack = {}
1✔
1120
                    for each HID in m.stack
1✔
1121
                        if Rotor.Utils.isDescendantHID(HID, ancestorHID)
3✔
1122
                            filteredStack[HID] = m.get(HID)
1✔
1123
                        end if
1124
                    end for
1125
                else
3✔
1126
                    filteredStack = m.stack
1✔
1127
                end if
1128
                HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
1✔
1129
                return HID <> "" ? m.get(HID) : invalid
1✔
1130
            end function
1131

1132
            override sub remove(HID as string)
1133
                item = m.get(HID)
1✔
1134
                item.destroy()
1✔
1135
                super.remove(HID)
1✔
1136
            end sub
1137

1138
        end class
1139

1140
        class GroupStack extends BaseEntryStack
1141

1142
            function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1143
                foundHID = ""
×
1144
                for each HID in possibleGroups
×
1145
                    group = m.get(HID)
×
1146
                    if group.id = nodeId
×
1147
                        foundHID = group.HID
×
1148
                        exit for
1149
                    end if
1150
                end for
1151
                return foundHID
×
1152
            end function
1153

1154
        end class
1155

1156

1157
        class FocusItemStack extends BaseEntryStack
1158

1159
            function convertNodeIdToHID(nodeId as string, possibleFocusItems as object) as string
1160
                foundHID = ""
×
1161
                for each HID in possibleFocusItems
×
1162
                    focusItem = m.get(HID)
×
1163
                    if focusItem.id = nodeId
×
1164
                        foundHID = focusItem.HID
×
1165
                        exit for
1166
                    end if
1167
                end for
1168
                return foundHID
×
1169
            end function
1170

1171
            function hasEnabled(HID as string) as boolean
1172
                if m.has(HID)
×
1173
                    focusItem = m.get(HID)
×
1174
                    return focusItem.isEnabled
×
1175
                else
×
1176
                    return false
×
1177
                end if
1178
            end function
1179

1180
        end class
1181

1182
        class BaseFocusConfig
1183

1184
            autoSetIsFocusedState as boolean
1185
            staticDirection as object
1186

1187
            sub new (config as object)
1188

1189
                m.HID = config.HID
1✔
1190
                m.id = config.id
1✔
1191

1192
                m.widget = config.widget
1✔
1193
                m.node = m.widget.node
1✔
1194
                m.isFocused = config.isFocused ?? false
1✔
1195

1196
                m.autoSetIsFocusedState = config.autoSetIsFocusedState ?? true
1✔
1197

1198
                m.isEnabled = config.isEnabled ?? true
1✔
1199
                m.staticDirection = {}
1✔
1200
                m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1✔
1201
                m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
1✔
1202
                m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
1✔
1203
                m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
1✔
1204
                m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
1✔
1205

1206
                m.onFocusChanged = config.onFocusChanged
1✔
1207
                m.longPressHandler = config.longPressHandler
1✔
1208
                m.onFocus = config.onFocus
1✔
1209
                m.onBlur = config.onBlur
1✔
1210

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

1213
                ' convenience (usually this is used on viewModelState)
1214
                if false = m.widget.viewModelState.DoesExist("isFocused") and true = m.autoSetIsFocusedState
2✔
1215
                    m.widget.viewModelState.isFocused = false ' as default
1✔
1216
                end if
1217

1218
            end sub
1219

1220

1221
            HID as string
1222
            id as string
1223
            idByKeys as object
1224
            isEnabled as boolean
1225
            isFocused as boolean
1226
            onFocusChanged as dynamic
1227
            onFocus as dynamic
1228
            onBlur as dynamic
1229
            longPressHandler as dynamic
1230
            node as object
1231
            widget as object
1232

1233
            function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1234
                direction = m.staticDirection[direction]
1✔
1235
                if Rotor.Utils.isFunction(direction)
2✔
1236
                    return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
×
1237
                else
3✔
1238
                    return direction ?? ""
1✔
1239
                end if
1240
            end function
1241

1242
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1243
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1244
                if true = isFocused
3✔
1245
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1246
                else
3✔
1247
                    Rotor.Utils.callbackScoped(m.onBlur, m.widget)
1✔
1248
                end if
1249
            end sub
1250

1251
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1252
                if Rotor.Utils.isFunction(m.longPressHandler)
2✔
1253
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1254
                else
3✔
1255
                    return false
1✔
1256
                end if
1257
            end function
1258

1259
            sub destroy()
1260
                m.widget = invalid
1✔
1261
                m.node = invalid
1✔
1262
                m.onFocusChanged = invalid
1✔
1263
                m.onFocus = invalid
1✔
1264
                m.onBlur = invalid
1✔
1265
                m.longPressHandler = invalid
1✔
1266
            end sub
1267

1268
        end class
1269

1270
        class GroupClass extends BaseFocusConfig
1271
            ' Note: Spatial navigation is supported within group, there is no spatial navigation between groups
1272
            ' If you want to focus out to another group, you need to config a direction prop.
1273
            ' You can set a groupId or any focusItem widgetId.
1274
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1275
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1276

1277
            sub new (config as object)
1278
                super(config)
1✔
1279
                m.defaultFocusId = config.defaultFocusId ?? ""
1✔
1280
                m.lastFocusedHID = config.lastFocusedHID ?? ""
1✔
1281
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
1✔
1282
            end sub
1283

1284
            defaultFocusId as string
1285
            lastFocusedHID as string
1286
            enableSpatialEnter as boolean
1287
            focusItemsRef as object
1288
            groupsRef as object
1289

1290
            isFocusItem = false
1291
            isGroup = true
1292

1293
            sub setLastFocusedHID(lastFocusedHID as string)
1294
                m.lastFocusedHID = lastFocusedHID
1✔
1295
            end sub
1296

1297
            function getGroupMembersHIDs()
1298
                ' Collect all focusItems that are descendants of this group
1299
                ' Exclude items that belong to nested sub-groups
1300
                focusItems = m.focusItemsRef.getAll()
1✔
1301
                groups = m.groupsRef.getAll()
1✔
1302
                HIDlen = Len(m.HID)
1✔
1303
                collection = []
1✔
1304
                groupsKeys = groups.keys()
1✔
1305
                groupsCount = groups.Count()
1✔
1306

1307
                for each focusItemHID in focusItems
1✔
1308
                    ' Check if focusItem is a descendant of this group
1309
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
1✔
1310
                    if isDescendant
2✔
1311
                        ' Check if focusItem belongs to a nested sub-group
1312
                        shouldExclude = false
1✔
1313
                        otherGroupIndex = 0
1✔
1314
                        while shouldExclude = false and otherGroupIndex < groupsCount
1✔
1315
                            otherGroupHID = groupsKeys[otherGroupIndex]
1✔
1316
                            otherGroupHIDlen = Len(otherGroupHID)
1✔
1317
                            ' Exclude if belongs to deeper nested group
1318
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
1✔
1319
                            otherGroupIndex++
1✔
1320
                        end while
1321

1322
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1323
                    end if
1324
                end for
1325

1326
                return collection
1✔
1327
            end function
1328

1329
            '
1330
            ' getFallbackNodeId - Returns the nodeId to use for fallback (defaultFocusId or lastFocusedHID)
1331
            '
1332
            ' @returns {string} The nodeId to use for fallback, or empty string if none
1333
            '
1334
            function getFallbackNodeId() as string
1335
                if m.lastFocusedHID <> ""
2✔
1336
                    ' Note: lastFocusedHID is already a HID, not a nodeId, so we need to get the nodeId
1337
                    lastFocusedItem = m.focusItemsRef.get(m.lastFocusedHID)
×
1338
                    if lastFocusedItem <> invalid
×
1339
                        return lastFocusedItem.id
×
1340
                    end if
1341
                end if
1342

1343
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1344
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1345
                else
3✔
1346
                    return m.defaultFocusId
1✔
1347
                end if
1348
            end function
1349

1350
            function getFallbackIdentifier() as string
1351
                HID = ""
1✔
1352
                if m.lastFocusedHID <> ""
2✔
1353
                    return m.lastFocusedHID
×
1354
                else
3✔
1355
                    if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1356
                        defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1357
                    else
3✔
1358
                        defaultFocusId = m.defaultFocusId
1✔
1359
                    end if
1360

1361
                    if defaultFocusId <> ""
3✔
1362
                        focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1363
                        if focusItemsHIDlist.Count() > 0
3✔
1364

1365
                            ' Try find valid HID in focusItems by node id
1366
                            focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1✔
1367
                            if focusItemHID <> ""
3✔
1368
                                HID = focusItemHID
1✔
1369
                            end if
1370

1371
                        else
1372

3✔
1373
                            return defaultFocusId
1✔
1374

1375
                        end if
1376
                    end if
1377

1378
                end if
1379

1380
                return HID
1✔
1381
            end function
1382

1383
            function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1384
                HID = ""
1✔
1385
                for each HID in focusItemsHIDlist
1✔
1386
                    focusItem = m.focusItemsRef.get(HID)
1✔
1387
                    if focusItem <> invalid and focusItem.id = nodeId
3✔
1388
                        HID = focusItem.HID
1✔
1389
                        exit for
1390
                    end if
1391
                end for
1392
                return HID
1✔
1393
            end function
1394

1395
            sub applyFocus(isFocused as boolean)
1396
                if m.isFocused = isFocused then return
2✔
1397

1398
                m.isFocused = isFocused
1✔
1399

1400
                if m.autoSetIsFocusedState
3✔
1401
                    m.widget.viewModelState.isInFocusChain = isFocused
1✔
1402
                end if
1403
                m.node.setField("isFocused", isFocused)
1✔
1404
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1405
            end sub
1406

1407
            override sub destroy()
1408
                super.destroy()
1✔
1409
                m.focusItemsRef = invalid
1✔
1410
                m.groupsRef = invalid
1✔
1411
            end sub
1412

1413

1414

1415
        end class
1416

1417
        class FocusItemClass extends BaseFocusConfig
1418

1419
            sub new (config as object)
1420
                super(config)
1✔
1421

1422
                m.onSelect = config.onSelect ?? ""
1✔
1423
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
1✔
1424
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1425
            end sub
1426

1427
            ' You can set a groupId or any focusItem widgetId.
1428
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1429
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1430

1431
            ' key as string
1432
            isFocusItem = true
1433
            isGroup = false
1434
            enableNativeFocus as boolean
1435
            enableSpatialNavigation as boolean
1436
            onSelect as dynamic
1437

1438
            private metrics = {
1439
                segments: {}
1440
            }
1441
            private bounding as object
1442

1443

1444
            sub refreshBounding()
1445
                b = m.node.sceneBoundingRect()
1✔
1446
                rotation = m.node.rotation
1✔
1447

1448
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1449
                ' That is why we can use translation without knowing the value of inheritParentTransform
1450
                ' If bounding x or y are not zero, then bounding will include the node's translation
1451
                if rotation = 0
3✔
1452
                    if b.y = 0 and b.x = 0
2✔
1453
                        t = m.node.translation
×
1454
                        b.x += t[0]
×
1455
                        b.y += t[1]
×
1456
                    end if
1457

1458
                    m.metrics.append(b)
1✔
1459
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
1✔
1460
                        x1: b.x, y1: b.y,
1461
                        x2: b.x, y2: b.y + b.height
1462
                    }
1463
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
1✔
1464
                        x1: b.x, y1: b.y,
1465
                        x2: b.x + b.width, y2: b.y
1466
                    }
1467
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
1✔
1468
                        x1: b.x + b.width, y1: b.y,
1469
                        x2: b.x + b.width, y2: b.y + b.height
1470
                    }
1471
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
1✔
1472
                        x1: b.x, y1: b.y + b.height,
1473
                        x2: b.x + b.width, y2: b.y + b.height
1474
                    }
1475
                    m.metrics.middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
1✔
1476
                else
×
1477
                    scaleRotateCenter = m.node.scaleRotateCenter
×
1478
                    dims = m.node.localBoundingRect() ' We need this to get proper (rotated value of rotated x and y)
×
1479
                    if b.y = 0 and b.x = 0
×
1480
                        t = m.node.translation
×
1481
                        b.x += t[0]
×
1482
                        b.y += t[1]
×
1483
                    end if
1484
                    b.width = dims.width
×
1485
                    b.height = dims.height
×
1486
                    m.metrics.append(b)
×
1487

1488
                    ' Calculate rotated segments
1489
                    segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
×
1490
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1491
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
×
1492

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

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

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

1505
                    ' Calculate rotated middle point
1506
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1507
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1508
                    m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
×
1509

1510
                end if
1511
            end sub
1512

1513
            override sub destroy()
1514
                m.onSelect = invalid
1✔
1515
                m.metrics.segments.Clear()
1✔
1516
                super.destroy()
1✔
1517
            end sub
1518

1519
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1520
                if m.isFocused = isFocused then return
2✔
1521

1522
                m.isFocused = isFocused
1✔
1523

1524
                if m.autoSetIsFocusedState
3✔
1525
                    m.widget.viewModelState.isFocused = isFocused
1✔
1526
                end if
1527

1528
                m.node.setField("isFocused", isFocused)
1✔
1529

1530
                if enableNativeFocus or m.enableNativeFocus
2✔
1531
                    m.node.setFocus(isFocused)
×
1532
                end if
1533

1534
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1535

1536
            end sub
1537

1538
            sub callOnSelectFnOnWidget()
1539
                Rotor.Utils.callbackScoped(m.onSelect, m.widget)
1✔
1540
            end sub
1541

1542
        end class
1543

1544
        class ClosestSegmentToPointCalculatorClass
1545

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

1549
                A = x - x1
1✔
1550
                B = y - y1
1✔
1551
                C = x2 - x1
1✔
1552
                D = y2 - y1
1✔
1553

1554
                dot = A * C + B * D
1✔
1555
                len_sq = C * C + D * D
1✔
1556
                param = -1
1✔
1557
                if len_sq <> 0
3✔
1558
                    param = dot / len_sq
1✔
1559
                end if
1560

1561
                xx = 0
1✔
1562
                yy = 0
1✔
1563

1564
                if param < 0
2✔
1565
                    xx = x1
×
1566
                    yy = y1
×
1567
                else if param > 1
2✔
1568
                    xx = x2
×
1569
                    yy = y2
×
1570
                else
3✔
1571
                    xx = x1 + param * C
1✔
1572
                    yy = y1 + param * D
1✔
1573
                end if
1574

1575
                dx = x - xx
1✔
1576
                dy = y - yy
1✔
1577
                return dx * dx + dy * dy
1✔
1578
            end function
1579

1580
            function distToSegment(p as object, s1 as object, s2 as object)
1581
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1582
            end function
1583

1584
        end class
1585

1586
    end namespace
1587

1588
    namespace FocusPluginHelper
1589

1590
        sub longPressObserverCallback(msg)
1591
            extraInfo = msg.GetInfo()
1✔
1592

1593
            pluginKey = extraInfo["pluginKey"]
1✔
1594

1595
            globalScope = GetGlobalAA()
1✔
1596
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
1✔
1597
            plugin = frameworkInstance.plugins[pluginKey]
1✔
1598
            plugin.isLongPress = true
1✔
1599
            ' plugin.longPressStartHID = plugin.globalFocusHID
1600
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
1✔
1601

1602
        end sub
1603

1604
    end namespace
1605

1606
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