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

mobalazs / rotor-framework / 20096587030

10 Dec 2025 11:11AM UTC coverage: 69.168% (-16.5%) from 85.707%
20096587030

Pull #11

github

web-flow
Merge 015752b30 into 5c8e97a93
Pull Request #11: Refactor/plugin adapter and decorators

52 of 84 new or added lines in 8 files covered. (61.9%)

328 existing lines in 3 files now uncovered.

1438 of 2079 relevant lines covered (69.17%)

0.92 hits per line

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

10.17
/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✔
NEW
190
                    #if debug
×
NEW
191
                        ? "[FOCUS_PLUGIN][ERROR] Widget '" + widget.id + "' (HID: " + widget.HID + ") cannot have both 'focus' and 'focusGroup' configurations!"
×
192
                    #end if
NEW
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
                ' Merge new config into existing widget config
NEW
215
                Rotor.Utils.deepExtendAA(widget[scope.pluginKey], newValue)
×
NEW
216
                scope.setFocusConfig(widget, widget[scope.pluginKey])
×
217
            end sub,
218

219
            ' ---------------------------------------------------------------------
220
            ' beforeDestroy - Hook executed before a widget is destroyed
221
            '
222
            ' Removes focus config.
223
            '
224
            ' @param {object} scope - The plugin scope (this instance)
225
            ' @param {object} widget - The widget being destroyed
226
            '
227
            beforeDestroy: sub(scope as object, widget as object)
UNCOV
228
                scope.removeFocusConfig(widget.HID)
×
229
            end sub
230
        }
231

232
        ' Widget methods - Injected into widgets managed by this plugin
233
        widgetMethods = {
234

235
            ' ---------------------------------------------------------------------
236
            ' enableFocusNavigation - Enables or disables focus navigation globally for this plugin
237
            '
238
            ' @param {boolean} enableFocusNavigation - True to enable, false to disable (default: true)
239
            '
240
            enableFocusNavigation: sub(enableFocusNavigation = true as boolean)
NEW
241
                m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].enableFocusNavigation = enableFocusNavigation
×
242
            end sub,
243

244
            ' ---------------------------------------------------------------------
245
            ' isFocusNavigationEnabled - Checks if focus navigation is currently enabled globally
246
            '
247
            ' @returns {boolean} True if enabled, false otherwise
248
            '
249
            isFocusNavigationEnabled: function() as boolean
NEW
250
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].enableFocusNavigation
×
251
            end function,
252

253
            ' ---------------------------------------------------------------------
254
            ' setFocus - Sets focus to this widget or another specified widget
255
            '
256
            ' @param {dynamic} isFocused - Boolean to focus/blur current widget, or string ID/HID of widget to focus
257
            ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node
258
            ' @returns {boolean} True if focus state was changed successfully, false otherwise
259
            '
260
            setFocus: function(command = true as dynamic, enableNativeFocus = false as boolean) as boolean
NEW
261
                plugin = m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY]
×
NEW
262
                HID = m.HID
×
263

UNCOV
264
                if Rotor.Utils.isString(command)
×
NEW
265
                    return plugin.setFocus(command, true, enableNativeFocus)
×
NEW
266
                else
×
NEW
267
                    return plugin.setFocus(HID, command, enableNativeFocus)
×
268
                end if
269
            end function,
270

271
            ' ---------------------------------------------------------------------
272
            ' getFocusedWidget - Retrieves the currently focused widget managed by this plugin
273
            '
274
            ' @returns {object} The widget instance that currently holds focus, or invalid
275
            '
276
            getFocusedWidget: function() as object
NEW
277
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].getFocusedWidget()
×
278
            end function,
279

280
            ' ---------------------------------------------------------------------
281
            ' proceedLongPress - Manually triggers the navigation action associated with the current long-press key
282
            '
283
            ' @returns {object} The result of the executed navigation action (see parseOnKeyEventResult)
284
            '
285
            proceedLongPress: function() as object
NEW
286
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].proceedLongPress()
×
287
            end function,
288

289
            ' ---------------------------------------------------------------------
290
            ' isLongPressActive - Checks if a long press action is currently active
291
            '
292
            ' @returns {boolean} True if a long press is active, false otherwise
293
            '
294
            isLongPressActive: function() as boolean
NEW
295
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].isLongPress
×
296
            end function,
297

298
            ' ---------------------------------------------------------------------
299
            ' triggerKeyPress - Simulate key press
300
            '
301
            ' @param {string} key - Pressed key
302
            ' @returns {object} The widget instance that currently holds focus, or invalid
303
            '
304
            triggerKeyPress: function(key) as object
NEW
305
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].onKeyEventHandler(key, true)
×
306
            end function
307

308
        }
309

310
        ' Configuration
311
        longPressDuration = 0.4
312
        enableLongPressFeature = true
313
        enableFocusNavigation = true
314

315
        ' State tracking
316
        globalFocusHID = ""
317
        globalFocusId = ""
318
        isLongPress = false
319
        longPressKey = ""
320

321
        ' References
322
        widgetTree as object
323
        frameworkInstance as Rotor.Framework
324

325
        ' Helper objects
326
        focusItemStack = new Rotor.FocusPluginHelper.FocusItemStack()
327
        groupStack = new Rotor.FocusPluginHelper.GroupStack()
328
        distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass()
329
        longPressTimer = CreateObject("roSGNode", "Timer")
330

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

347
        '
348
        ' storeGlobalFocusHID - Stores the globally focused widget's HID and ID
349
        '
350
        ' @param {string} HID - The Hierarchical ID of the focused widget
351
        ' @param {string} id - The regular ID of the focused widget
352
        '
353
        sub storeGlobalFocusHID(HID as string, id as string)
354
            ' Store focus reference within the plugin
UNCOV
355
            m.globalFocusHID = HID
×
UNCOV
356
            m.globalFocusId = id
×
357
        end sub
358

359
        '
360
        ' getFocusedWidget - Gets the widget instance that currently holds global focus
361
        '
362
        ' @returns {object} The focused widget object, or invalid if none
363
        '
364
        function getFocusedWidget() as object
UNCOV
365
            return m.getFocusedItem()?.widget
×
366
        end function
367

368
        '
369
        ' getFocusedItem - Gets the FocusItem instance corresponding to the globally focused widget
370
        '
371
        ' @returns {object} The FocusItem instance, or invalid if none
372
        '
373
        function getFocusedItem() as object
UNCOV
374
            return m.focusItemStack.get(m.globalFocusHID)
×
375
        end function
376

377
        '
378
        ' setFocusConfig - Configures focus properties (FocusItem and/or Group) for a widget
379
        '
380
        ' @param {object} widget - The widget to configure
381
        ' @param {object} pluginConfig - The focus configuration object from the widget's spec
382
        '
383
        sub setFocusConfig(widget as object, pluginConfig as object)
384

385
            if pluginConfig = invalid then return ' No config provided
2✔
386
            HID = widget.HID
1✔
387
            id = widget.id
1✔
388

389
            ' Make a copy to avoid modifying the original config
390
            config = Rotor.Utils.deepCopy(pluginConfig)
1✔
391

392
            ' Determine if this is a group configuration
393
            ' NEW: Check if plugin key is GROUP_FOCUS_PLUGIN_KEY (explicit group)
394
            ' BACKWARD COMPAT: Check if config has "group" property (old syntax: focus: { group: {...} })
395

396
            groupConfig = invalid
1✔
397
            if widget.DoesExist(GROUP_FOCUS_PLUGIN_KEY)
2✔
398
                ' New syntax: focusGroup: {...}
NEW
399
                groupConfig = config
×
400
            else if config.doesExist(Rotor.Const.GROUP_CONFIG_KEY)
3✔
401
                ' Backward compatibility: focus: { group: {...} }
402
                groupConfig = config[Rotor.Const.GROUP_CONFIG_KEY]
1✔
403
            end if
404

405
            ' Ensure essential identifiers are in the config
406
            config.id = id
1✔
407
            config.HID = widget.HID
1✔
408

409
            ' Handle group configuration if present
410
            if groupConfig <> invalid
3✔
411
                m.setupGroup(HID, groupConfig, widget)
1✔
412
            else
UNCOV
413
                ' Handle focus item configuration if applicable
×
UNCOV
414
                m.setupFocusItem(HID, config, widget)
×
415
            end if
416
        end sub
417

418
        '
419
        ' setupGroup - Creates and registers a new Focus Group based on configuration
420
        '
421
        ' @param {string} HID - The Hierarchical ID of the widget acting as the group root
422
        ' @param {object} config - The full focus configuration for the widget
423
        ' @param {object} widget - The widget instance itself
424
        '
425
        sub setupGroup(HID as string, config as object, widget as object)
426
            ' Handle both old and new syntax for group config
427
            ' NEW: focusGroup: { defaultFocusId: ... } - config is already the group config
428
            ' OLD: focus: { group: { defaultFocusId: ... } } - need to extract group config
429
            if config.doesExist(Rotor.Const.GROUP_CONFIG_KEY)
2✔
430
                ' Old syntax: extract group config from "group" property
NEW
431
                groupConfig = config[Rotor.Const.GROUP_CONFIG_KEY]
×
432
            else
433
                ' New syntax: config is already the group config
3✔
434
                groupConfig = config
1✔
435
            end if
436

437
            ' Copy essential info to the group-specific config
438
            groupConfig.id = config.id
1✔
439
            groupConfig.HID = config.HID
1✔
440
            groupConfig.widget = widget
1✔
441
            ' Create and configure the Group instance
442
            newGroup = new Rotor.FocusPluginHelper.GroupClass(groupConfig)
1✔
443
            newGroup.focusItemsRef = m.focusItemStack ' Provide reference to focus items
1✔
444
            newGroup.groupsRef = m.groupStack ' Provide reference to other groups
1✔
445
            m.groupStack.set(config.HID, newGroup) ' Register the new group
1✔
446
        end sub
447

448
        '
449
        ' setupFocusItem - Creates and registers a new Focus Item based on configuration
450
        '
451
        ' @param {string} HID - The Hierarchical ID of the focusItem widget
452
        ' @param {object} config - The full focus configuration for the widget
453
        ' @param {object} widget - The widget instance itself
454
        '
455
        sub setupFocusItem(HID as string, config as object, widget as object)
UNCOV
456
            config.widget = widget ' Ensure widget reference is in the config
×
457

458
            ' Create and register the FocusItem instance
UNCOV
459
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
×
UNCOV
460
            m.focusItemStack.set(HID, newFocusItem)
×
461
        end sub
462

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

481
            ' Note:
482
            ' - Parent group is at index 0
483
            ' - If HID is a focusItem, its direct parent group is included
484
            ' - If HID is a group, the group itself is NOT included
UNCOV
485
            return ancestorGroups
×
486
        end function
487

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

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

516
            ' Resolve reference (HID or ID) to a focusItem item.
UNCOV
517
            focusItem = invalid ' Initialize target focus item
×
518

519
            ' Exit if reference is empty or invalid.
UNCOV
520
            if ref = invalid or ref = "" then return false
×
521

UNCOV
522
            if m.focusItemStack.has(ref)
×
523
                ' Case 1: ref is a valid focusItem HID.
UNCOV
524
                focusItem = m.focusItemStack.get(ref)
×
525
            else
UNCOV
526
                ' Case 2: ref might be a focusItem node ID.
×
UNCOV
527
                focusItem = m.focusItemStack.getByNodeId(ref)
×
528

UNCOV
529
                if focusItem = invalid
×
530
                    ' Case 3: ref might be a group HID or group node ID.
531
                    ' Try finding group by HID first, then by Node ID.
UNCOV
532
                    group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
×
UNCOV
533
                    if group <> invalid
×
534
                        ' If group found, find its default/entry focus item recursively.
UNCOV
535
                        HID = m.capturingFocus_recursively(group.HID)
×
UNCOV
536
                        focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
×
537

538
                        ' else: ref is not a known FocusItem HID or Group identifier
539
                    end if
540
                end if
541
            end if
542

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

563
            ' Found a valid focusItem to target
UNCOV
564
            HID = focusItem.HID
×
565

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

570
            ' Cannot focus an invisible item.
UNCOV
571
            if focusItem.node.visible = false and isFocused = true then return false
×
572

573
            ' Determine if native focus should be enabled (request or item default)
UNCOV
574
            enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
×
575

576
            ' Prevent focusing a disabled item.
UNCOV
577
            preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
×
UNCOV
578
            if preventFocusOnDisabled
×
579
                return false ' Indicate focus change failed
×
580
            end if
581

582
            ' Prepare ancestor groups for notification (from highest ancestor to closest parent)
NEW
583
            focusChainGroups = m.findAncestorGroups(focusItem.HID) ' Groups containing the new focus
×
UNCOV
584
            lastFocusChainingGroups = []
×
585

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

604
            ' Prepare notification list: all affected groups (unique)
NEW
605
            allAffectedGroups = []
×
NEW
606
            for each groupHID in focusChainGroups
×
NEW
607
                allAffectedGroups.unshift(groupHID) ' Add in reverse order (highest ancestor first)
×
608
            end for
NEW
609
            for each groupHID in lastFocusChainingGroups
×
NEW
610
                if -1 = Rotor.Utils.findInArray(allAffectedGroups, groupHID)
×
NEW
611
                    allAffectedGroups.unshift(groupHID) ' Add in reverse order if not already present
×
612
                end if
613
            end for
614

615
            ' Notify all ancestor groups BEFORE applying focus (from highest ancestor to closest parent)
NEW
616
            m.notifyFocusAtAncestorGroups(focusItem.HID, allAffectedGroups)
×
617

618
            ' Blur the previously focused item (after notification)
NEW
619
            if m.globalFocusHID <> "" and lastFocused <> invalid
×
NEW
620
                lastFocused.applyFocus(false, enableNativeFocus)
×
621
            end if
622

623
            ' Apply focus state (focused/blurred) to the target item.
UNCOV
624
            focusItem.applyFocus(isFocused, enableNativeFocus)
×
625

626
            ' Update the globally tracked focused item.
UNCOV
627
            m.storeGlobalFocusHID(isFocused ? HID : "", isFocused ? focusItem.id : "")
×
628

629
            ' Ensure SceneGraph root has focus if native focus wasn't explicitly enabled on the item.
UNCOV
630
            if enableNativeFocus = false
×
UNCOV
631
                globalScope = GetGlobalAA()
×
UNCOV
632
                if globalScope.top.isInFocusChain() = false
×
UNCOV
633
                    globalScope.top.setFocus(true)
×
634
                end if
635
            end if
636

UNCOV
637
            return true
×
638

639
        end function
640

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

649
            ' Notify all ancestor groups
UNCOV
650
            if groupHIDs.Count() > 0
×
UNCOV
651
                for each groupHID in groupHIDs
×
652

UNCOV
653
                    group = m.groupStack.get(groupHID)
×
UNCOV
654
                    isInFocusChain = Rotor.Utils.isAncestorHID(groupHID, HID)
×
UNCOV
655
                    group.applyFocus(isInFocusChain)
×
656

657
                end for
658
            end if
659
        end sub
660

661
        sub notifyLongPressAtAncestorGroups(isLongPress as boolean, key as string, HID as string, groupHIDs = [] as object)
662
            ' Notify all ancestor groups
663
            if groupHIDs.Count() > 0
×
664
                for each groupHID in groupHIDs
×
665
                    group = m.groupStack.get(groupHID)
×
666
                    handled = group.callLongPressHandler(isLongPress, key)
×
667
                    if handled then exit for
×
668
                end for
669
            end if
670
        end sub
671

672
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
673
            focused = m.getFocusedItem()
×
674
            handled = focused.callLongPressHandler(isLongPress, key)
×
675
            if handled then return
×
676

677
            focusChainGroups = m.findAncestorGroups(focused.HID)
×
678
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
×
679
        end sub
680

681
        function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object) as string
UNCOV
682
            if focused.enableSpatialNavigation = false then return ""
×
UNCOV
683
            if direction = Rotor.Const.Direction.BACK then return ""
×
684

685
            ' Remove current focused item from candidates
UNCOV
686
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
×
UNCOV
687
            if index >= 0 then focusItemsHIDlist.delete(index)
×
688

689
            ' Find closest focusable item in direction
UNCOV
690
            segments = m.collectSegments(focused, direction, focusItemsHIDlist)
×
UNCOV
691
            if segments.Count() > 0
×
UNCOV
692
                return m.findClosestSegment(segments, focused.metrics.middlePoint)
×
693
            end if
694

UNCOV
695
            return ""
×
696
        end function
697

698
        function findClosestSegment(segments as object, middlePoint as object) as string
UNCOV
699
            distances = []
×
700

701
            ' Calculate distance from middle point to each segment
UNCOV
702
            for each HID in segments
×
UNCOV
703
                segment = segments[HID]
×
UNCOV
704
                distance = m.distanceCalculator.distToSegment(middlePoint, {
×
705
                    x: segment.x1,
706
                    y: segment.y1
707
                }, {
708
                    x: segment.x2,
709
                    y: segment.y2
710
                })
711

UNCOV
712
                distances.push({
×
713
                    HID: HID,
714
                    distance: distance
715
                })
716
            end for
717

718
            ' Find segment with minimum distance
UNCOV
719
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
×
720
                return a < b
721
            end function)
722

UNCOV
723
            return minDistItem.HID
×
724
        end function
725

726

727
        ' Waterfall of fallback's of groups (linked together with defaultFocusId)
728
        function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
729
            ' Resolve identifier to a group
UNCOV
730
            group = m.groupStack.get(identifier)
×
UNCOV
731
            if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
×
UNCOV
732
            if group = invalid then return ""
×
733

734
            ' Get fallback identifier for this group
UNCOV
735
            newHID = group.getFallbackIdentifier()
×
736

737
            ' Check if we found a FocusItem
UNCOV
738
            if m.focusItemStack.has(newHID)
×
739
                ' Apply spatial enter feature if enabled
UNCOV
740
                if group.enableSpatialEnter = true and direction <> ""
×
741
                    focused = m.focusItemStack.get(m.globalFocusHID)
×
742
                    newSpatialHID = m.spatialNavigation(focused, direction, group.getGroupMembersHIDs())
×
743
                    if newSpatialHID <> "" then newHID = newSpatialHID
×
744
                end if
745

UNCOV
746
            else if newHID <> ""
×
747
                ' Try to find as group first, then deep search
UNCOV
748
                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
×
749

750
                ' If still not found, perform deep search in all descendants
UNCOV
751
                if newHID = ""
×
UNCOV
752
                    newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
×
753
                end if
754
            end if
755

756
            ' Prevent capturing by fallback in the same group where original focus was
UNCOV
757
            if newHID <> "" and m.globalFocusHID <> ""
×
UNCOV
758
                currentAncestors = m.findAncestorGroups(m.globalFocusHID)
×
UNCOV
759
                newAncestors = m.findAncestorGroups(newHID)
×
UNCOV
760
                if currentAncestors.Count() > 0 and newAncestors.Count() > 0
×
UNCOV
761
                    if currentAncestors[0] = newAncestors[0] then newHID = ""
×
762
                end if
763
            end if
764

UNCOV
765
            return newHID
×
766
        end function
767

768
        '
769
        ' deepSearchFocusItemByNodeId - Deep search for a FocusItem or Group by nodeId within a group hierarchy
770
        '
771
        ' @param {string} groupHID - The HID of the group to search within
772
        ' @param {string} nodeId - The node ID to search for
773
        ' @returns {string} The HID of the found FocusItem or Group, or empty string if not found
774
        '
775
        function deepSearchFocusItemByNodeId(groupHID as string, nodeId as string) as string
UNCOV
776
            if nodeId = "" then return ""
×
777

778
            ' Get all descendants of this group (both FocusItems and nested Groups)
UNCOV
779
            allFocusItems = m.focusItemStack.getAll()
×
UNCOV
780
            allGroups = m.groupStack.getAll()
×
781

782
            ' First, search in direct and nested FocusItems
UNCOV
783
            for each focusItemHID in allFocusItems
×
UNCOV
784
                if Rotor.Utils.isDescendantHID(focusItemHID, groupHID)
×
UNCOV
785
                    focusItem = m.focusItemStack.get(focusItemHID)
×
UNCOV
786
                    if focusItem <> invalid and focusItem.id = nodeId
×
787
                        return focusItemHID
×
788
                    end if
789
                end if
790
            end for
791

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

809
            return ""
×
810
        end function
811

812
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
UNCOV
813
            newHID = ""
×
814

815
            ' Build ancestor chain (current group + all ancestors)
UNCOV
816
            ancestorGroups = m.findAncestorGroups(groupHID)
×
UNCOV
817
            ancestorGroups.unshift(groupHID)
×
UNCOV
818
            ancestorGroupsCount = ancestorGroups.Count()
×
UNCOV
819
            ancestorIndex = 0
×
820

821
            ' Bubble up through ancestor groups until we find a target or reach the top
UNCOV
822
            while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
×
823
                ' Get next ancestor group
UNCOV
824
                groupHID = ancestorGroups[ancestorIndex]
×
UNCOV
825
                group = m.groupStack.get(groupHID)
×
826

827
                ' Check group's direction configuration
UNCOV
828
                nodeId = group.getStaticNodeIdInDirection(direction)
×
829

UNCOV
830
                if Rotor.Utils.isBoolean(nodeId)
×
831
                    ' Boolean means focus is explicitly handled
UNCOV
832
                    if nodeId = true
×
UNCOV
833
                        newHID = true ' Block navigation (exit loop)
×
834
                    else
×
835
                        newHID = "" ' Continue bubbling
×
836
                    end if
837
                else
UNCOV
838
                    ' String nodeId - try to resolve target
×
UNCOV
839
                    if nodeId <> ""
×
UNCOV
840
                        otherGroup = m.groupStack.getByNodeId(nodeId)
×
UNCOV
841
                        if otherGroup <> invalid
×
UNCOV
842
                            newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
×
843
                        end if
844
                    end if
845
                end if
846

UNCOV
847
                ancestorIndex++
×
848
            end while
849

UNCOV
850
            return newHID
×
851
        end function
852

853
        ' * KEY EVENT HANDLER
854
        function onKeyEventHandler(key as string, press as boolean) as object
855
            ' Check long-press
UNCOV
856
            if m.enableLongPressFeature = true
×
857
                m.checkLongPressState(key, press)
×
858
            end if
859
            ' Prevent any navigation if it is disabled
UNCOV
860
            #if debug
×
UNCOV
861
                if m.enableFocusNavigation = false and press = true then print "[PLUGIN][FOCUS][INFO] Focus navigation is disabled. Call enableFocusNavigation(true) to make it enabled"
×
862
            #end if
UNCOV
863
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
×
864
            ' Execute action according to key press
UNCOV
865
            return m.executeNavigationAction(key, press)
×
866
        end function
867

868
        function executeNavigationAction(key as string, press as boolean) as object
869

UNCOV
870
            if true = press
×
871

UNCOV
872
                if -1 < Rotor.Utils.findInArray([
×
873
                        Rotor.Const.Direction.UP,
874
                        Rotor.Const.Direction.RIGHT,
875
                        Rotor.Const.Direction.DOWN,
876
                        Rotor.Const.Direction.LEFT,
877
                        Rotor.Const.Direction.BACK
878
                    ], key)
879

UNCOV
880
                    newHID = ""
×
UNCOV
881
                    direction = key
×
882

883
                    ' (1) Pick up current focused item
884

UNCOV
885
                    focused = m.focusItemStack.get(m.globalFocusHID)
×
886

UNCOV
887
                    if focused = invalid
×
888
                        #if debug
×
889
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue detected. Last known focus id:\"${m.globalFocusHID}\". Please ensure valid focus.`
×
890
                        #end if
891
                        return m.parseOnKeyEventResult(key, false, false)
×
892
                    end if
893

894

UNCOV
895
                    ancestorGroups = m.findAncestorGroups(focused.HID)
×
UNCOV
896
                    ancestorGroupsCount = ancestorGroups.Count()
×
897

UNCOV
898
                    if ancestorGroupsCount = 0
×
899
                        allFocusItems = m.focusItemStack.getAll()
×
900
                        possibleFocusItems = allFocusItems.keys()
×
901
                        parentGroupHID = ""
×
UNCOV
902
                    else
×
UNCOV
903
                        parentGroupHID = ancestorGroups[0]
×
UNCOV
904
                        group = m.groupStack.get(parentGroupHID)
×
UNCOV
905
                        possibleFocusItems = group.getGroupMembersHIDs()
×
906
                    end if
907

908
                    ' (2) Try static direction, defined on the focusItem, among possible focusItems
UNCOV
909
                    nodeId = focused.getStaticNodeIdInDirection(direction) ' Note that this is a nodeId
×
910

UNCOV
911
                    if Rotor.Utils.isBoolean(nodeId) and nodeId = true
×
912
                        ' It means that focus is handled, and no need further action by plugin.
913
                        return m.parseOnKeyEventResult(key, true, false)
×
914
                    end if
915

UNCOV
916
                    if nodeId <> ""
×
917
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
918
                    end if
919

UNCOV
920
                    if newHID = ""
×
921
                        ' (3) Try spatial navigation in direction, among possible focusItems
922
                        ' all = m.focusItemStack.getAll()
923
                        ' allKeys = all.Keys()
UNCOV
924
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
×
925
                    end if
926

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

UNCOV
940
                    handled = m.setFocus(newHID)
×
UNCOV
941
                    return m.parseOnKeyEventResult(key, handled, false)
×
942

943
                else if key = "OK"
×
944

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

947
                end if
948
            end if
949

950
            return m.parseOnKeyEventResult(key, false, false)
×
951

952
        end function
953

954
        function parseOnKeyEventResult(key as string, handled as boolean, isSelected as boolean) as object
UNCOV
955
            result = {
×
956
                handled: handled,
957
                key: key
958
            }
UNCOV
959
            if m.globalFocusHID <> "" and handled = true
×
UNCOV
960
                focusItem = m.focusItemStack.get(m.globalFocusHID)
×
UNCOV
961
                widget = m.widgetTree.get(focusItem.HID)
×
962
                ' viewModelState = Rotor.Utils.deepCopy(widget.viewModelState)
UNCOV
963
                result.widget = widget
×
UNCOV
964
                if isSelected
×
965
                    result.isSelected = isSelected
×
966
                    focusItem.callOnSelectedFnOnWidget()
×
967
                end if
968
            end if
UNCOV
969
            return result
×
970
        end function
971

972
        sub checkLongPressState(key as string, press as boolean)
973
            m.longPressTimer.control = "stop"
×
974
            if press = true
×
975
                if m.isLongPress = false
×
976
                    m.longPressKey = key
×
977
                    m.longPressTimer.control = "start"
×
978
                end if
979
            else
×
980
                wasLongPress = m.isLongPress = true
×
981
                lastKey = m.longPressKey
×
982
                m.isLongPress = false
×
983
                m.longPressKey = ""
×
984
                if wasLongPress
×
985
                    m.delegateLongPressChanged(false, lastKey)
×
986
                end if
987
            end if
988
        end sub
989

990
        function proceedLongPress() as object
991
            return m.executeNavigationAction(m.longPressKey, true)
×
992
        end function
993

994
        ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
995
        function collectSegments(focused as object, direction as string, focusItemsHIDlist as object) as object
UNCOV
996
            focused.refreshBounding()
×
997

UNCOV
998
            refSegmentTop = focused.metrics.segments[Rotor.Const.Segment.TOP]
×
UNCOV
999
            refSegmentRight = focused.metrics.segments[Rotor.Const.Segment.RIGHT]
×
UNCOV
1000
            refSegmentLeft = focused.metrics.segments[Rotor.Const.Segment.LEFT]
×
UNCOV
1001
            refSegmentBottom = focused.metrics.segments[Rotor.Const.Segment.BOTTOM]
×
UNCOV
1002
            referencePoint = { x: (refSegmentTop.x1 + refSegmentRight.x2) / 2, y: (refSegmentTop.y1 + refSegmentRight.y2) / 2 }
×
1003

UNCOV
1004
            validators = {
×
1005

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

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

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

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

UNCOV
1048
            return segments
×
1049
        end function
1050

1051
        sub destroy()
1052
            ' Remove all groups
1053
            for each HID in m.groupStack.getAll()
×
1054
                m.groupStack.remove(HID)
×
1055
            end for
1056
            ' Remove all focus items
1057
            for each HID in m.focusItemStack.getAll()
×
1058
                m.focusItemStack.remove(HID)
×
1059
            end for
1060
            m.longPressTimer.unobserveFieldScoped("fire")
×
1061
            m.longPressTimer = invalid
×
1062
            m.widgetTree = invalid
×
1063
        end sub
1064

1065
    end class
1066

1067
    namespace FocusPluginHelper
1068

1069
        class BaseEntryStack extends Rotor.BaseStack
1070

1071
            function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
UNCOV
1072
                if ancestorHID <> "0"
×
UNCOV
1073
                    filteredStack = {}
×
UNCOV
1074
                    for each HID in m.stack
×
UNCOV
1075
                        if Rotor.Utils.isDescendantHID(HID, ancestorHID)
×
UNCOV
1076
                            filteredStack[HID] = m.get(HID)
×
1077
                        end if
1078
                    end for
UNCOV
1079
                else
×
UNCOV
1080
                    filteredStack = m.stack
×
1081
                end if
UNCOV
1082
                HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
×
UNCOV
1083
                return HID <> "" ? m.get(HID) : invalid
×
1084
            end function
1085

1086
            override sub remove(HID as string)
UNCOV
1087
                item = m.get(HID)
×
UNCOV
1088
                item.destroy()
×
UNCOV
1089
                super.remove(HID)
×
1090
            end sub
1091

1092
        end class
1093

1094
        class GroupStack extends BaseEntryStack
1095

1096
            function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1097
                foundHID = ""
×
1098
                for each HID in possibleGroups
×
1099
                    group = m.get(HID)
×
1100
                    if group.id = nodeId
×
1101
                        foundHID = group.HID
×
1102
                        exit for
1103
                    end if
1104
                end for
1105
                return foundHID
×
1106
            end function
1107

1108
        end class
1109

1110

1111
        class FocusItemStack extends BaseEntryStack
1112

1113
            function convertNodeIdToHID(nodeId as string, possibleFocusItems as object) as string
1114
                foundHID = ""
×
1115
                for each HID in possibleFocusItems
×
1116
                    focusItem = m.get(HID)
×
1117
                    if focusItem.id = nodeId
1118
                        foundHID = focusItem.HID
×
1119
                        exit for
1120
                    end if
1121
                end for
1122
                return foundHID
×
1123
            end function
1124

1125
            function hasEnabled(HID as string) as boolean
1126
                if m.has(HID)
×
1127
                    focusItem = m.get(HID)
×
1128
                    return focusItem.isEnabled
×
1129
                else
×
1130
                    return false
×
1131
                end if
1132
            end function
1133

1134
        end class
1135

1136
        class BaseFocusConfig
1137

1138
            autoSetIsFocusedOnContext as boolean
1139
            staticDirection as object
1140

1141
            sub new (config as object)
1142

1143
                m.HID = config.HID
1✔
1144
                m.id = config.id
1✔
1145

1146
                m.widget = config.widget
1✔
1147
                m.node = m.widget.node
1✔
1148
                m.isFocused = config.isFocused ?? false
1149

1150
                m.autoSetIsFocusedOnContext = config.autoSetIsFocusedOnContext ?? true
1✔
1151

1152
                m.isEnabled = config.isEnabled ?? true
1✔
1153
                m.staticDirection = {}
1✔
1154
                m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1✔
1155
                m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
1✔
1156
                m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
1✔
1157
                m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
1✔
1158
                m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
1✔
1159

1160
                m.onFocusChanged = config.onFocusChanged
1✔
1161
                m.longPressHandler = config.longPressHandler
1✔
1162
                m.onFocus = config.onFocus
1✔
1163

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

1166
                ' convenience (usually this is used on viewModelState)
1167
                if false = m.widget.viewModelState.DoesExist("isFocused") and true = m.autoSetIsFocusedOnContext
3✔
1168
                    m.widget.viewModelState.isFocused = false ' as default
1✔
1169
                end if
1170

1171
            end sub
1172

1173

1174
            HID as string
1175
            id as string
1176
            idByKeys as object
1177
            isEnabled as boolean
1178
            isFocused as boolean
1179
            onFocusChanged as dynamic
1180
            onFocus as dynamic
1181
            longPressHandler as dynamic
1182
            node as object
1183
            widget as object
1184

1185
            function getStaticNodeIdInDirection(direction as dynamic) as dynamic
UNCOV
1186
                direction = m.staticDirection[direction]
×
UNCOV
1187
                if Rotor.Utils.isFunction(direction)
×
1188
                    return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
×
UNCOV
1189
                else
×
UNCOV
1190
                    return direction ?? ""
×
1191
                end if
1192
            end function
1193

1194
            sub callOnFocusedFnOnWidget(isFocused as boolean)
UNCOV
1195
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
×
UNCOV
1196
                if true = isFocused
×
UNCOV
1197
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
×
1198
                end if
1199
            end sub
1200

1201
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1202
                if Rotor.Utils.isFunction(m.longPressHandler)
×
1203
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1204
                else
×
1205
                    return false
×
1206
                end if
1207
            end function
1208

1209
            sub destroy()
UNCOV
1210
                m.widget = invalid
×
UNCOV
1211
                m.node = invalid
×
UNCOV
1212
                m.onFocusChanged = invalid
×
UNCOV
1213
                m.longPressHandler = invalid
×
1214
            end sub
1215

1216
        end class
1217

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

1225
            sub new (config as object)
1226
                super(config)
1✔
1227
                m.defaultFocusId = config.defaultFocusId ?? ""
1✔
1228
                m.lastFocusedHID = config.lastFocusedHID ?? ""
1✔
1229
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
1✔
1230
            end sub
1231

1232
            defaultFocusId as string
1233
            lastFocusedHID as string
1234
            enableSpatialEnter as boolean
1235
            focusItemsRef as object
1236
            groupsRef as object
1237

1238
            isFocusItem = false
1239
            isGroup = true
1240

1241
            sub setLastFocusedHID(lastFocusedHID as string)
UNCOV
1242
                m.lastFocusedHID = lastFocusedHID
×
1243
            end sub
1244

1245
            function getGroupMembersHIDs()
1246
                ' Collect all focusItems that are descendants of this group
1247
                ' Exclude items that belong to nested sub-groups
UNCOV
1248
                focusItems = m.focusItemsRef.getAll()
×
UNCOV
1249
                groups = m.groupsRef.getAll()
×
UNCOV
1250
                HIDlen = Len(m.HID)
×
UNCOV
1251
                collection = []
×
UNCOV
1252
                groupsKeys = groups.keys()
×
UNCOV
1253
                groupsCount = groups.Count()
×
1254

UNCOV
1255
                for each focusItemHID in focusItems
×
1256
                    ' Check if focusItem is a descendant of this group
UNCOV
1257
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
×
UNCOV
1258
                    if isDescendant
×
1259
                        ' Check if focusItem belongs to a nested sub-group
UNCOV
1260
                        shouldExclude = false
×
UNCOV
1261
                        otherGroupIndex = 0
×
UNCOV
1262
                        while shouldExclude = false and otherGroupIndex < groupsCount
×
UNCOV
1263
                            otherGroupHID = groupsKeys[otherGroupIndex]
×
UNCOV
1264
                            otherGroupHIDlen = Len(otherGroupHID)
×
1265
                            ' Exclude if belongs to deeper nested group
UNCOV
1266
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
×
UNCOV
1267
                            otherGroupIndex++
×
1268
                        end while
1269

UNCOV
1270
                        if not shouldExclude then collection.push(focusItemHID)
×
1271
                    end if
1272
                end for
1273

UNCOV
1274
                return collection
×
1275
            end function
1276

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

UNCOV
1291
                if Rotor.Utils.isFunction(m.defaultFocusId)
×
1292
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
UNCOV
1293
                else
×
UNCOV
1294
                    return m.defaultFocusId
×
1295
                end if
1296
            end function
1297

1298
            function getFallbackIdentifier() as string
UNCOV
1299
                HID = ""
×
UNCOV
1300
                if m.lastFocusedHID <> ""
×
1301
                    return m.lastFocusedHID
×
UNCOV
1302
                else
×
UNCOV
1303
                    if Rotor.Utils.isFunction(m.defaultFocusId)
×
1304
                        defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
UNCOV
1305
                    else
×
UNCOV
1306
                        defaultFocusId = m.defaultFocusId
×
1307
                    end if
1308

UNCOV
1309
                    if defaultFocusId <> ""
×
UNCOV
1310
                        focusItemsHIDlist = m.getGroupMembersHIDs()
×
UNCOV
1311
                        if focusItemsHIDlist.Count() > 0
×
1312

1313
                            ' Try find valid HID in focusItems by node id
UNCOV
1314
                            focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
×
UNCOV
1315
                            if focusItemHID <> ""
×
UNCOV
1316
                                HID = focusItemHID
×
1317
                            end if
1318

1319
                        else
UNCOV
1320

×
UNCOV
1321
                            return defaultFocusId
×
1322

1323
                        end if
1324
                    end if
1325

1326
                end if
1327

UNCOV
1328
                return HID
×
1329
            end function
1330

1331
            function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
UNCOV
1332
                HID = ""
×
UNCOV
1333
                for each HID in focusItemsHIDlist
×
UNCOV
1334
                    focusItem = m.focusItemsRef.get(HID)
×
UNCOV
1335
                    if focusItem <> invalid and focusItem.id = nodeId
×
UNCOV
1336
                        HID = focusItem.HID
×
1337
                        exit for
1338
                    end if
1339
                end for
UNCOV
1340
                return HID
×
1341
            end function
1342

1343
            sub applyFocus(isFocused as boolean)
UNCOV
1344
                if m.isFocused = isFocused then return
×
1345

UNCOV
1346
                m.isFocused = isFocused
×
1347

UNCOV
1348
                if m.autoSetIsFocusedOnContext
×
UNCOV
1349
                    m.widget.viewModelState.isInFocusChain = isFocused
×
1350
                end if
UNCOV
1351
                m.node.setField("isFocused", isFocused)
×
UNCOV
1352
                m.callOnFocusedFnOnWidget(isFocused)
×
1353
            end sub
1354

1355
            override sub destroy()
UNCOV
1356
                super.destroy()
×
UNCOV
1357
                m.focusItemsRef = invalid
×
UNCOV
1358
                m.groupsRef = invalid
×
1359
            end sub
1360

1361

1362

1363
        end class
1364

1365
        class FocusItemClass extends BaseFocusConfig
1366

1367
            sub new (config as object)
UNCOV
1368
                super(config)
×
1369

UNCOV
1370
                m.onSelected = config.onSelected ?? ""
×
UNCOV
1371
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
×
UNCOV
1372
                m.enableNativeFocus = config.enableNativeFocus ?? false
×
1373
            end sub
1374

1375
            ' You can set a groupId or any focusItem widgetId.
1376
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1377
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1378

1379
            ' key as string
1380
            isFocusItem = true
1381
            isGroup = false
1382
            enableNativeFocus as boolean
1383
            enableSpatialNavigation as boolean
1384
            onSelected as dynamic
1385

1386
            private metrics = {
1387
                segments: {}
1388
            }
1389
            private bounding as object
1390

1391

1392
            sub refreshBounding()
UNCOV
1393
                b = m.node.sceneBoundingRect()
×
UNCOV
1394
                rotation = m.node.rotation
×
1395

1396
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1397
                ' That is why we can use translation without knowing the value of inheritParentTransform
1398
                ' If bounding x or y are not zero, then bounding will include the node's translation
UNCOV
1399
                if rotation = 0
×
UNCOV
1400
                    if b.y = 0 and b.x = 0
×
1401
                        t = m.node.translation
×
1402
                        b.x += t[0]
×
1403
                        b.y += t[1]
×
1404
                    end if
1405

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

1436
                    ' Calculate rotated segments
1437
                    segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
×
1438
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1439
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
×
1440

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

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

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

1453
                    ' Calculate rotated middle point
1454
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1455
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1456
                    m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
×
1457

1458
                end if
1459
            end sub
1460

1461
            override sub destroy()
UNCOV
1462
                m.onSelected = invalid
×
UNCOV
1463
                m.metrics.segments.Clear()
×
UNCOV
1464
                super.destroy()
×
1465
            end sub
1466

1467
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
UNCOV
1468
                if m.isFocused = isFocused then return
×
1469

UNCOV
1470
                m.isFocused = isFocused
×
1471

UNCOV
1472
                if m.autoSetIsFocusedOnContext
×
UNCOV
1473
                    m.widget.viewModelState.isFocused = isFocused
×
1474
                end if
1475

UNCOV
1476
                m.node.setField("isFocused", isFocused)
×
1477

UNCOV
1478
                if enableNativeFocus or m.enableNativeFocus
×
1479
                    m.node.setFocus(isFocused)
×
1480
                end if
1481

UNCOV
1482
                m.callOnFocusedFnOnWidget(isFocused)
×
1483

1484
            end sub
1485

1486
            sub callOnSelectedFnOnWidget()
1487
                Rotor.Utils.callbackScoped(m.onSelected, m.widget)
×
1488
            end sub
1489

1490
        end class
1491

1492
        class ClosestSegmentToPointCalculatorClass
1493

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

UNCOV
1497
                A = x - x1
×
UNCOV
1498
                B = y - y1
×
UNCOV
1499
                C = x2 - x1
×
UNCOV
1500
                D = y2 - y1
×
1501

UNCOV
1502
                dot = A * C + B * D
×
UNCOV
1503
                len_sq = C * C + D * D
×
UNCOV
1504
                param = -1
×
UNCOV
1505
                if len_sq <> 0
×
UNCOV
1506
                    param = dot / len_sq
×
1507
                end if
1508

UNCOV
1509
                xx = 0
×
UNCOV
1510
                yy = 0
×
1511

UNCOV
1512
                if param < 0
×
1513
                    xx = x1
×
1514
                    yy = y1
×
UNCOV
1515
                else if param > 1
×
1516
                    xx = x2
×
1517
                    yy = y2
×
UNCOV
1518
                else
×
UNCOV
1519
                    xx = x1 + param * C
×
UNCOV
1520
                    yy = y1 + param * D
×
1521
                end if
1522

UNCOV
1523
                dx = x - xx
×
UNCOV
1524
                dy = y - yy
×
UNCOV
1525
                return dx * dx + dy * dy
×
1526
            end function
1527

1528
            function distToSegment(p as object, s1 as object, s2 as object)
UNCOV
1529
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
×
1530
            end function
1531

1532
        end class
1533

1534
    end namespace
1535

1536
    namespace FocusPluginHelper
1537

1538
        sub longPressObserverCallback(msg)
1539
            extraInfo = msg.GetInfo()
×
1540

1541
            pluginKey = extraInfo["pluginKey"]
×
1542

1543
            globalScope = GetGlobalAA()
×
1544
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
×
1545
            plugin = frameworkInstance.plugins[pluginKey]
×
1546
            plugin.isLongPress = true
×
1547
            ' plugin.longPressStartHID = plugin.globalFocusHID
1548
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
×
1549

1550
        end sub
1551

1552
    end namespace
1553

1554
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