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

mobalazs / rotor-framework / 20639436795

01 Jan 2026 01:33PM UTC coverage: 85.409% (-0.07%) from 85.476%
20639436795

push

github

mobalazs
feat(FieldsPlugin): add support for converting \n to Chr(10) in field values

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

2 existing lines in 2 files now uncovered.

1996 of 2337 relevant lines covered (85.41%)

1.17 hits per line

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

79.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✔
190
                    #if debug
×
191
                        ? "[FOCUS_PLUGIN][ERROR] Widget '" + widget.id + "' (HID: " + widget.HID + ") cannot have both 'focus' and 'focusGroup' configurations!"
×
192
                    #end if
193
                    return ' Skip setup for this widget
×
194
                end if
195

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

326
        }
327

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

579
            lastFocusChainingGroups = []
1✔
580

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

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

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

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

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

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

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

632
            return true
1✔
633

634
        end function
635

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

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

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

652
                end for
653
            end if
654
        end sub
655

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

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

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

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

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

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

690
            return ""
1✔
691
        end function
692

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

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

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

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

718
            return minDistItem.HID
1✔
719
        end function
720

721

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

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

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

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

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

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

760
            return newHID
1✔
761
        end function
762

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

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

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

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

804
            return ""
×
805
        end function
806

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

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

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

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

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

842
                ancestorIndex++
1✔
843
            end while
844

845
            return newHID
1✔
846
        end function
847

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

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

865
            if true = press
3✔
866

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

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

878
                    ' (1) Pick up current focused item
879

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

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

889

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

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

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

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

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

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

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

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

938
                else if key = "OK"
3✔
939

940
                    return m.parseOnKeyEventResult(key, true, true)
1✔
941

942
                end if
943
            end if
944

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

947
        end function
948

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

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

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

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

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

999
            validators = {
1✔
1000

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

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

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

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

1043
            return segments
1✔
1044
        end function
1045

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

1060
    end class
1061

1062
    namespace FocusPluginHelper
1063

1064
        class BaseEntryStack extends Rotor.BaseStack
1065

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

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

1087
        end class
1088

1089
        class GroupStack extends BaseEntryStack
1090

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

1103
        end class
1104

1105

1106
        class FocusItemStack extends BaseEntryStack
1107

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

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

1129
        end class
1130

1131
        class BaseFocusConfig
1132

1133
            autoSetIsFocusedOnContext as boolean
1134
            staticDirection as object
1135

1136
            sub new (config as object)
1137

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

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

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

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

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

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

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

1167
            end sub
1168

1169

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

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

1191
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1192
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1193
                if true = isFocused
3✔
1194
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1195
                else
3✔
1196
                    Rotor.Utils.callbackScoped(m.onBlur, m.widget)
1✔
1197
                end if
1198
            end sub
1199

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

1208
            sub destroy()
1209
                m.widget = invalid
1✔
1210
                m.node = invalid
1✔
1211
                m.onFocusChanged = invalid
1✔
1212
                m.onFocus = invalid
1✔
1213
                m.onBlur = invalid
1✔
1214
                m.longPressHandler = invalid
1✔
1215
            end sub
1216

1217
        end class
1218

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

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

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

1239
            isFocusItem = false
1240
            isGroup = true
1241

1242
            sub setLastFocusedHID(lastFocusedHID as string)
1243
                m.lastFocusedHID = lastFocusedHID
1✔
1244
            end sub
1245

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

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

1271
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1272
                    end if
1273
                end for
1274

1275
                return collection
1✔
1276
            end function
1277

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

1292
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1293
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1294
                else
3✔
1295
                    return m.defaultFocusId
1✔
1296
                end if
1297
            end function
1298

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

1310
                    if defaultFocusId <> ""
3✔
1311
                        focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1312
                        if focusItemsHIDlist.Count() > 0
3✔
1313

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

1320
                        else
1321

3✔
1322
                            return defaultFocusId
1✔
1323

1324
                        end if
1325
                    end if
1326

1327
                end if
1328

1329
                return HID
1✔
1330
            end function
1331

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

1344
            sub applyFocus(isFocused as boolean)
1345
                if m.isFocused = isFocused then return
2✔
1346

1347
                m.isFocused = isFocused
1✔
1348

1349
                if m.autoSetIsFocusedOnContext
3✔
1350
                    m.widget.viewModelState.isInFocusChain = isFocused
1✔
1351
                end if
1352
                m.node.setField("isFocused", isFocused)
1✔
1353
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1354
            end sub
1355

1356
            override sub destroy()
1357
                super.destroy()
1✔
1358
                m.focusItemsRef = invalid
1✔
1359
                m.groupsRef = invalid
1✔
1360
            end sub
1361

1362

1363

1364
        end class
1365

1366
        class FocusItemClass extends BaseFocusConfig
1367

1368
            sub new (config as object)
1369
                super(config)
1✔
1370

1371
                m.onSelect = config.onSelect ?? ""
1✔
1372
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
1✔
1373
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1374
            end sub
1375

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

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

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

1392

1393
            sub refreshBounding()
1394
                b = m.node.sceneBoundingRect()
1✔
1395
                rotation = m.node.rotation
1✔
1396

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

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

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

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

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

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

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

1459
                end if
1460
            end sub
1461

1462
            override sub destroy()
1463
                m.onSelect = invalid
1✔
1464
                m.metrics.segments.Clear()
1✔
1465
                super.destroy()
1✔
1466
            end sub
1467

1468
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1469
                if m.isFocused = isFocused then return
2✔
1470

1471
                m.isFocused = isFocused
1✔
1472

1473
                if m.autoSetIsFocusedOnContext
3✔
1474
                    m.widget.viewModelState.isFocused = isFocused
1✔
1475
                end if
1476

1477
                m.node.setField("isFocused", isFocused)
1✔
1478

1479
                if enableNativeFocus or m.enableNativeFocus
2✔
1480
                    m.node.setFocus(isFocused)
×
1481
                end if
1482

1483
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1484

1485
            end sub
1486

1487
            sub callOnSelectFnOnWidget()
1488
                Rotor.Utils.callbackScoped(m.onSelect, m.widget)
1✔
1489
            end sub
1490

1491
        end class
1492

1493
        class ClosestSegmentToPointCalculatorClass
1494

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

1498
                A = x - x1
1✔
1499
                B = y - y1
1✔
1500
                C = x2 - x1
1✔
1501
                D = y2 - y1
1✔
1502

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

1510
                xx = 0
1✔
1511
                yy = 0
1✔
1512

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

1524
                dx = x - xx
1✔
1525
                dy = y - yy
1✔
1526
                return dx * dx + dy * dy
1✔
1527
            end function
1528

1529
            function distToSegment(p as object, s1 as object, s2 as object)
1530
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1531
            end function
1532

1533
        end class
1534

1535
    end namespace
1536

1537
    namespace FocusPluginHelper
1538

1539
        sub longPressObserverCallback(msg)
1540
            extraInfo = msg.GetInfo()
1✔
1541

1542
            pluginKey = extraInfo["pluginKey"]
1✔
1543

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

1551
        end sub
1552

1553
    end namespace
1554

1555
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