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

mobalazs / rotor-framework / 22325902662

23 Feb 2026 09:38PM UTC coverage: 90.089% (-0.06%) from 90.146%
22325902662

push

github

mobalazs
chore: bump version to 0.8.2

2218 of 2462 relevant lines covered (90.09%)

1.26 hits per line

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

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

169
    const PRIMARY_FOCUS_PLUGIN_KEY = "focus"
170
    const GROUP_FOCUS_PLUGIN_KEY = "focusGroup"
171
    class FocusPlugin extends Rotor.BasePlugin
172

173
        pluginKey = PRIMARY_FOCUS_PLUGIN_KEY
174
        aliasPluginKey = GROUP_FOCUS_PLUGIN_KEY
175

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

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

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

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

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

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

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

236
                scope.setFocusConfig(widget, widget[targetKey])
1✔
237
            end sub,
238

239
            ' ---------------------------------------------------------------------
240
            ' beforeDestroy - Hook executed before a widget is destroyed
241
            '
242
            ' Removes focus config. Clears global focus if this widget had it.
243
            '
244
            ' @param {object} scope - The plugin scope (this instance)
245
            ' @param {object} widget - The widget being destroyed
246
            '
247
            beforeDestroy: sub(scope as object, widget as object)
248
                hadFocus = scope.globalFocusHID = widget.HID
249
                scope.removeFocusConfig(widget.HID)
1✔
250
                if hadFocus
3✔
251
                    scope.storeGlobalFocusHID("", "")
1✔
252
                end if
253
            end sub
254
        }
255

256
        ' Widget methods - Injected into widgets managed by this plugin
257
        widgetMethods = {
258

259
            ' ---------------------------------------------------------------------
260
            ' enableFocusNavigation - Enables or disables focus navigation globally for this plugin
261
            '
262
            ' @param {boolean} enableFocusNavigation - True to enable, false to disable (default: true)
263
            '
264
            enableFocusNavigation: sub(enableFocusNavigation = true as boolean)
265
                m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].enableFocusNavigation = enableFocusNavigation
1✔
266
            end sub,
267

268
            ' ---------------------------------------------------------------------
269
            ' isFocusNavigationEnabled - Checks if focus navigation is currently enabled globally
270
            '
271
            ' @returns {boolean} True if enabled, false otherwise
272
            '
273
            isFocusNavigationEnabled: function() as boolean
274
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].enableFocusNavigation
1✔
275
            end function,
276

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

288
                if Rotor.Utils.isString(command)
2✔
289
                    return plugin.setFocus(command, true, enableNativeFocus)
1✔
290
                else
3✔
291
                    return plugin.setFocus(HID, command, enableNativeFocus)
1✔
292
                end if
293
            end function,
294

295
            ' ---------------------------------------------------------------------
296
            ' getFocusedWidget - Retrieves the currently focused widget managed by this plugin
297
            '
298
            ' @returns {object} The widget instance that currently holds focus, or invalid
299
            '
300
            getFocusedWidget: function() as object
301
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].getFocusedWidget()
1✔
302
            end function,
303

304
            ' ---------------------------------------------------------------------
305
            ' proceedLongPress - Manually triggers the navigation action associated with the current long-press key
306
            '
307
            ' @returns {object} The result of the executed navigation action (see parseOnKeyEventResult)
308
            '
309
            proceedLongPress: function() as object
310
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].proceedLongPress()
1✔
311
            end function,
312

313
            ' ---------------------------------------------------------------------
314
            ' isLongPressActive - Checks if a long press action is currently active
315
            '
316
            ' @returns {boolean} True if a long press is active, false otherwise
317
            '
318
            isLongPressActive: function() as boolean
319
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].isLongPress
1✔
320
            end function,
321

322
            ' ---------------------------------------------------------------------
323
            ' triggerKeyPress - Simulate key press
324
            '
325
            ' @param {string} key - Pressed key
326
            ' @returns {object} The widget instance that currently holds focus, or invalid
327
            '
328
            triggerKeyPress: function(key) as object
329
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].onKeyEventHandler(key, true)
1✔
330
            end function,
331

332
            ' ---------------------------------------------------------------------
333
            ' setGroupLastFocusedId - Updates the lastFocusedHID of this widget's focus group
334
            '
335
            ' If called on a focusGroup widget, updates its own lastFocusedHID.
336
            ' If called on a focusItem widget, finds and updates the parent group's lastFocusedHID.
337
            '
338
            ' @param {string} id - The widget id to set as lastFocusedHID
339
            '
340
            setGroupLastFocusedId: sub(id as string)
341
                plugin = m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY]
1✔
342

343
                ' Determine ancestorHID for search context
344
                ' If this is a focus item, use parent group's HID to find siblings
345
                ancestorGroups = plugin.findAncestorGroups(m.HID)
1✔
346
                if ancestorGroups.count() > 0
2✔
347
                    ancestorHID = ancestorGroups[0]
1✔
348
                else
3✔
349
                    ancestorHID = m.HID
1✔
350
                end if
351

352
                ' Resolve id to HID - check focusItemStack first, then groupStack
353
                focusItem = plugin.focusItemStack.getByNodeId(id, ancestorHID)
1✔
354
                if focusItem <> invalid
2✔
355
                    resolvedHID = focusItem.HID
1✔
356
                else
3✔
357
                    group = plugin.groupStack.getByNodeId(id, ancestorHID)
1✔
358
                    if group <> invalid
3✔
359
                        resolvedHID = group.HID
1✔
360
                    else
3✔
361
                        return
362
                    end if
363
                end if
364

365
                ' Check if this widget is a group
366
                group = plugin.groupStack.get(m.HID)
1✔
367
                if group <> invalid
3✔
368
                    group.setLastFocusedHID(resolvedHID)
1✔
369
                    return
1✔
370
                end if
371

372
                ' This is a focus item - find parent group
373
                ancestorGroups = plugin.findAncestorGroups(m.HID)
1✔
374
                if ancestorGroups.count() > 0
3✔
375
                    parentGroupHID = ancestorGroups[0]
1✔
376
                    parentGroup = plugin.groupStack.get(parentGroupHID)
1✔
377
                    if parentGroup <> invalid
3✔
378
                        parentGroup.setLastFocusedHID(resolvedHID)
1✔
379
                    end if
380
                end if
381
            end sub,
382

383
            ' ---------------------------------------------------------------------
384
            ' isFocused - Checks if this widget currently has focus
385
            '
386
            ' For a FocusItem: returns whether this item is the focused element
387
            ' For a Group: returns whether any descendant within this group has focus (is in focus chain)
388
            ' For widgets without focus config: returns false
389
            '
390
            ' @returns {boolean} True if focused (or in focus chain for groups), false otherwise
391
            '
392
            isFocused: function() as boolean
393
                plugin = m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY]
1✔
394
                focusItem = plugin.focusItemStack.get(m.HID)
1✔
395
                if focusItem <> invalid
3✔
396
                    return focusItem.isFocused
1✔
397
                end if
398
                group = plugin.groupStack.get(m.HID)
1✔
399
                if group <> invalid
3✔
400
                    return group.isFocused
1✔
401
                end if
402
                return false
×
403
            end function
404

405
        }
406

407
        ' Configuration
408
        longPressDuration = 0.4
409
        enableLongPressFeature = true
410
        enableFocusNavigation = true
411

412
        ' State tracking
413
        globalFocusHID = ""
414
        globalFocusId = ""
415
        lastNavigationDirection = ""
416
        isLongPress = false
417
        longPressKey = ""
418

419
        ' References
420
        widgetTree as object
421
        frameworkInstance as Rotor.Framework
422

423
        ' Helper objects
424
        focusItemStack = new Rotor.FocusPluginHelper.FocusItemStack()
425
        groupStack = new Rotor.FocusPluginHelper.GroupStack()
426
        distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass()
427
        longPressTimer = CreateObject("roSGNode", "Timer")
428

429
        ' Spatial navigation direction validators (reused across calls)
430
        spatialValidators = {
431
            "left": function(segments as object, refSegmentLeft as object, refSegmentRight as object) as object
432
                right = segments[Rotor.Const.Segment.RIGHT]
1✔
433
                return right.x2 <= refSegmentLeft.x1 ? { isValid: true, segment: right } : { isValid: false }
1✔
434
            end function,
435
            "up": function(segments as object, refSegmentTop as object, refSegmentBottom as object) as object
436
                bottom = segments[Rotor.Const.Segment.BOTTOM]
1✔
437
                return bottom.y2 <= refSegmentTop.y1 ? { isValid: true, segment: bottom } : { isValid: false }
1✔
438
            end function,
439
            "right": function(segments as object, refSegmentLeft as object, refSegmentRight as object) as object
440
                left = segments[Rotor.Const.Segment.LEFT]
1✔
441
                return left.x1 >= refSegmentRight.x2 ? { isValid: true, segment: left } : { isValid: false }
1✔
442
            end function,
443
            "down": function(segments as object, refSegmentTop as object, refSegmentBottom as object) as object
444
                top = segments[Rotor.Const.Segment.TOP]
1✔
445
                return top.y1 >= refSegmentBottom.y2 ? { isValid: true, segment: top } : { isValid: false }
1✔
446
            end function
447
        }
448

449
        ' ---------------------------------------------------------------------
450
        ' init - Initializes the plugin instance
451
        '
452
        ' Sets up internal state and helpers.
453
        '
454
        sub init ()
455
            m.widgetTree = m.frameworkInstance.builder.widgetTree ' Reference to the main widget tree
1✔
456
            m.longPressTimer.addField("pluginKey", "string", false)
1✔
457
            m.longPressTimer.setFields({
1✔
458
                "pluginKey": m.pluginKey,
459
                duration: m.longPressDuration
460
            })
461
            ' Observe timer fire event to handle long press callback
462
            m.longPressTimer.observeFieldScoped("fire", "Rotor_FocusPluginHelper_longPressObserverCallback", ["pluginKey"])
1✔
463
        end sub
464

465
        '
466
        ' storeGlobalFocusHID - Stores the globally focused widget's HID and ID
467
        '
468
        ' @param {string} HID - The Hierarchical ID of the focused widget
469
        ' @param {string} id - The regular ID of the focused widget
470
        '
471
        sub storeGlobalFocusHID(HID as string, id as string)
472
            ' Store focus reference within the plugin
473
            m.globalFocusHID = HID
1✔
474
            m.globalFocusId = id
1✔
475
        end sub
476

477
        '
478
        ' getFocusedWidget - Gets the widget instance that currently holds global focus
479
        '
480
        ' @returns {object} The focused widget object, or invalid if none
481
        '
482
        function getFocusedWidget() as object
483
            return m.getFocusedItem()?.widget
1✔
484
        end function
485

486
        '
487
        ' getFocusedItem - Gets the FocusItem instance corresponding to the globally focused widget
488
        '
489
        ' @returns {object} The FocusItem instance, or invalid if none
490
        '
491
        function getFocusedItem() as object
492
            return m.focusItemStack.get(m.globalFocusHID)
1✔
493
        end function
494

495
        '
496
        ' setFocusConfig - Configures focus properties (FocusItem and/or Group) for a widget
497
        '
498
        ' @param {object} widget - The widget to configure
499
        ' @param {object} pluginConfig - The focus configuration object from the widget's spec
500
        '
501
        sub setFocusConfig(widget as object, pluginConfig as object)
502

503
            if pluginConfig = invalid then return ' No config provided
2✔
504
            HID = widget.HID
1✔
505
            id = widget.id
1✔
506

507
            ' Make a copy to avoid modifying the original config
508
            config = Rotor.Utils.deepCopy(pluginConfig)
1✔
509

510
            ' Ensure essential identifiers are in the config
511
            config.id = id
1✔
512
            config.HID = widget.HID
1✔
513

514
            ' Handle group configuration if present
515
            if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY)
3✔
516
                ' Handle focus item configuration if applicable
517
                m.setupFocusItem(HID, config, widget)
1✔
518
            else
519
                ' Handle group configuration
3✔
520
                m.setupGroup(HID, config, widget)
1✔
521
            end if
522
        end sub
523

524
        '
525
        ' setupGroup - Creates and registers a new Focus Group based on configuration
526
        '
527
        ' @param {string} HID - The Hierarchical ID of the widget acting as the group root
528
        ' @param {object} config - The full focus configuration for the widget
529
        ' @param {object} widget - The widget instance itself
530
        '
531
        sub setupGroup(HID as string, config as object, widget as object)
532
            ' Copy essential info to the group-specific config
533
            config.id = config.id
1✔
534
            config.HID = config.HID
1✔
535
            config.widget = widget
1✔
536
            ' Create and configure the Group instance
537
            newGroup = new Rotor.FocusPluginHelper.GroupClass(config)
1✔
538
            newGroup.focusItemsRef = m.focusItemStack ' Provide reference to focus items
1✔
539
            newGroup.groupsRef = m.groupStack ' Provide reference to other groups
1✔
540
            m.groupStack.set(config.HID, newGroup) ' Register the new group
1✔
541
        end sub
542

543
        '
544
        ' setupFocusItem - Creates and registers a new Focus Item based on configuration
545
        '
546
        ' @param {string} HID - The Hierarchical ID of the focusItem widget
547
        ' @param {object} config - The full focus configuration for the widget
548
        ' @param {object} widget - The widget instance itself
549
        '
550
        sub setupFocusItem(HID as string, config as object, widget as object)
551
            config.widget = widget ' Ensure widget reference is in the config
1✔
552

553
            ' Create and register the FocusItem instance
554
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
1✔
555
            m.focusItemStack.set(HID, newFocusItem)
1✔
556

557
            ' Restore focus state if this widget had global focus
558
            if m.globalFocusHID = HID
2✔
559
                newFocusItem.isFocused = true
1✔
560
            end if
561
        end sub
562

563
        '
564
        ' findAncestorGroups - Finds all ancestor groups for a given widget HID
565
        '
566
        ' @param {string} HID - The Hierarchical ID of the widget
567
        ' @returns {object} An roArray of ancestor group HIDs, sorted with the immediate parent first (descending HID length)
568
        '
569
        function findAncestorGroups(HID as string) as object
570
            allGroups = m.groupStack.getAll() ' Get all registered groups
1✔
571
            ancestorGroups = []
1✔
572
            ' Iterate through all groups to find ancestors
573
            for each groupHID in allGroups
1✔
574
                if Rotor.Utils.isAncestorHID(groupHID, HID)
3✔
575
                    ancestorGroups.push(groupHID)
1✔
576
                end if
577
            end for
578
            ' Sort by HID length descending (parent first)
579
            ancestorGroups.Sort("r")
1✔
580

581
            ' Note:
582
            ' - Parent group is at index 0
583
            ' - If HID is a focusItem, its direct parent group is included
584
            ' - If HID is a group, the group itself is NOT included
585
            return ancestorGroups
1✔
586
        end function
587

588
        '
589
        ' removeFocusConfig - Removes focus configuration (Group and/or FocusItem) for a widget
590
        '
591
        ' @param {string} HID - The Hierarchical ID of the widget whose config should be removed
592
        '
593
        sub removeFocusConfig(HID as string)
594
            ' Remove associated group, if it exists
595
            if m.groupStack.has(HID)
2✔
596
                m.groupStack.remove(HID)
1✔
597
            end if
598
            ' Remove associated focus item, if it exists
599
            if m.focusItemStack.has(HID)
3✔
600
                m.focusItemStack.remove(HID)
1✔
601
            end if
602
        end sub
603

604
        '
605
        ' setFocus - Sets or removes focus from a specific widget or group
606
        '
607
        ' Handles focus state changes, callbacks, and native focus interaction.
608
        '
609
        ' @param {dynamic} ref - The target: HID (string) of a FocusItem or Group, or Node ID (string) of a Group
610
        ' @param {boolean} isFocused - True to set focus, false to remove focus (default: true)
611
        ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node (default: false)
612
        ' @returns {boolean} True if the focus state was successfully changed, false otherwise
613
        '
614
        function setFocus(ref as dynamic, isFocused = true as boolean, enableNativeFocus = false as boolean) as boolean
615

616
            ' Resolve reference (HID or ID) to a focusItem item.
617
            focusItem = invalid ' Initialize target focus item
1✔
618

619
            ' Exit if reference is empty or invalid.
620
            if ref = invalid or ref = "" then return false
2✔
621

622
            if m.focusItemStack.has(ref)
3✔
623
                ' Case 1: ref is a valid focusItem HID.
624
                focusItem = m.focusItemStack.get(ref)
1✔
625
            else
626
                ' Case 2: ref might be a focusItem node ID.
3✔
627
                focusItem = m.focusItemStack.getByNodeId(ref)
1✔
628

629
                if focusItem = invalid
3✔
630
                    ' Case 3: ref might be a group HID or group node ID.
631
                    ' Try finding group by HID first, then by Node ID.
632
                    group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
1✔
633
                    if group <> invalid
3✔
634
                        ' If group found, find its default/entry focus item recursively.
635
                        ' Use lastNavigationDirection so enableSpatialEnter groups can pick the right entry point
636
                        HID = m.capturingFocus_recursively(group.HID, m.lastNavigationDirection)
1✔
637
                        focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
1✔
638
                        ' else: ref is not a known FocusItem HID or Group identifier
639
                    end if
640
                end if
641
            end if
642

643
            ' Handle case where the target focus item could not be found or resolved.
644
            if focusItem = invalid
2✔
645
                focused = m.focusItemStack.get(m.globalFocusHID) ' Check current focus
1✔
646
                #if debug
4✔
647
                    ' Log warnings if focus target is not found
648
                    if focused = invalid
2✔
649
                        print `[PLUGIN][FOCUS][WARNING] Requested focus target ref: "${ref}" was not found or resolved to a valid FocusItem.`
×
650
                        if m.globalFocusHID = ""
×
651
                            ' If global focus is also lost, indicate potential issue.
652
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue likely. No current focus set. Ensure valid initial focus.`
×
653
                        else
×
654
                            print `[PLUGIN][FOCUS][WARNING] Current focus HID: "${m.globalFocusHID}". Ensure target "${ref}" is registered and reachable.`
×
655
                        end if
656
                    else
3✔
657
                        print `[PLUGIN][FOCUS][WARNING] Could not find focus target ref: "${ref}". Current focus remains on HID: "${m.globalFocusHID}", id"${m.globalFocusId}"".`
1✔
658
                    end if
659
                #end if
660
                return false ' Indicate focus change failed
1✔
661
            end if
662

663
            ' Found a valid focusItem to target
664
            HID = focusItem.HID
1✔
665

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

670
            ' Cannot focus an invisible item.
671
            if focusItem.node.visible = false and isFocused = true then return false
2✔
672

673
            ' Determine if native focus should be enabled (request or item default)
674
            enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
1✔
675

676
            ' Prevent focusing a disabled item.
677
            preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
1✔
678
            if preventFocusOnDisabled
2✔
679
                return false ' Indicate focus change failed
×
680
            end if
681

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

685
            lastFocusChainingGroups = []
1✔
686

687
            ' Handle blurring the previously focused item
688
            if m.globalFocusHID <> "" ' If something was focused before
2✔
689
                lastFocused = m.focusItemStack.get(m.globalFocusHID)
1✔
690
                if lastFocused <> invalid ' Check if the last focused widget hasn't been destroyed
3✔
691
                    ' Record the last focused item within its parent group for potential future use (e.g., returning focus)
692
                    lastFocusChainingGroups = m.findAncestorGroups(m.globalFocusHID)
1✔
693
                    for i = 0 to lastFocusChainingGroups.Count() - 1
1✔
694
                        ancestorGroupHID = lastFocusChainingGroups[i]
1✔
695
                        ancestorGroup = m.groupStack.get(ancestorGroupHID)
1✔
696
                        if ancestorGroup <> invalid
3✔
697
                            ' For immediate parent (index 0): set if enableLastFocusId is true (default)
698
                            ' For other ancestors: set if enableDeepLastFocusId is enabled
699
                            shouldSetLastFocusId = (i = 0 and ancestorGroup.enableLastFocusId) or (i > 0 and ancestorGroup.enableDeepLastFocusId)
1✔
700
                            if shouldSetLastFocusId
3✔
701
                                ancestorGroup.setLastFocusedHID(m.globalFocusHID)
1✔
702
                            end if
703
                        end if
704
                    end for
705
                end if
706
            end if
707

708
            ' Prepare notification list: all affected groups (unique)
709
            allAffectedGroups = []
1✔
710
            for each groupHID in focusChainGroups
1✔
711
                allAffectedGroups.unshift(groupHID) ' Add in reverse order (highest ancestor first)
1✔
712
            end for
713
            for i = 0 to lastFocusChainingGroups.Count() - 1
1✔
714
                groupHID = lastFocusChainingGroups[i]
1✔
715

716
                ' Add to allAffectedGroups if not present
717
                if -1 = Rotor.Utils.findInArray(allAffectedGroups, groupHID)
2✔
718
                    allAffectedGroups.unshift(groupHID)
1✔
719
                end if
720
            end for
721

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

725
            ' Blur the previously focused item (after notification)
726
            if m.globalFocusHID <> "" and lastFocused <> invalid
2✔
727
                lastFocused.applyFocus(false, enableNativeFocus)
1✔
728
            end if
729

730
            ' Apply focus state (focused/blurred) to the target item.
731
            focusItem.applyFocus(isFocused, enableNativeFocus)
1✔
732

733
            ' Update the globally tracked focused item.
734
            m.storeGlobalFocusHID(isFocused ? HID : "", isFocused ? focusItem.id : "")
1✔
735

736
            ' Ensure SceneGraph root has focus if native focus wasn't explicitly enabled on the item.
737
            if enableNativeFocus = false
3✔
738
                globalScope = GetGlobalAA()
1✔
739
                if globalScope.top.isInFocusChain() = false
2✔
740
                    globalScope.top.setFocus(true)
1✔
741
                end if
742
            end if
743

744
            return true
1✔
745

746
        end function
747

748
        '
749
        ' notifyFocusAtAncestorGroups - Applies the correct focus state (in focus chain or not) to a list of group HIDs
750
        '
751
        ' @param {string} HID - The HID of the item that ultimately received/lost focus
752
        ' @param {object} groupHIDs - An roArray of group HIDs to notify
753
        '
754
        sub notifyFocusAtAncestorGroups(HID as string, groupHIDs = [] as object)
755

756
            ' Notify all ancestor groups
757
            if groupHIDs.Count() > 0
3✔
758
                for each groupHID in groupHIDs
1✔
759

760
                    group = m.groupStack.get(groupHID)
1✔
761
                    isInFocusChain = Rotor.Utils.isAncestorHID(groupHID, HID)
1✔
762
                    group.applyFocus(isInFocusChain)
1✔
763

764
                end for
765
            end if
766
        end sub
767

768
        sub notifyLongPressAtAncestorGroups(isLongPress as boolean, key as string, HID as string, groupHIDs = [] as object)
769
            ' Notify all ancestor groups
770
            if groupHIDs.Count() > 0
3✔
771
                for each groupHID in groupHIDs
1✔
772
                    group = m.groupStack.get(groupHID)
1✔
773
                    handled = group.callLongPressHandler(isLongPress, key)
1✔
774
                    if handled then exit for
2✔
775
                end for
776
            end if
777
        end sub
778

779
        function delegateKeyPress(key as string) as boolean
780
            focused = m.getFocusedItem()
1✔
781
            if focused = invalid then return false
2✔
782

783
            ' Bubble through ancestor groups (focused item already checked by caller)
784
            focusChainGroups = m.findAncestorGroups(focused.HID)
1✔
785
            for each groupHID in focusChainGroups
1✔
786
                group = m.groupStack.get(groupHID)
1✔
787
                handled = group.callKeyPressHandler(key)
1✔
788
                if handled then return true
2✔
789
            end for
790

791
            return false
1✔
792
        end function
793

794
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
795
            focused = m.getFocusedItem()
1✔
796
            handled = focused.callLongPressHandler(isLongPress, key)
1✔
797
            if handled then return
2✔
798

799
            focusChainGroups = m.findAncestorGroups(focused.HID)
1✔
800
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
1✔
801
        end sub
802

803
        function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object, bypassFocusedCheck = false as boolean) as string
804
            ' Skip if focused item doesn't participate in spatial nav (unless bypassed for cross-group nav)
805
            if not bypassFocusedCheck and focused.enableSpatialNavigation = false then return ""
2✔
806
            if direction = Rotor.Const.Direction.BACK then return ""
2✔
807

808
            ' Remove current focused item from candidates
809
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
1✔
810
            if index >= 0 then focusItemsHIDlist.delete(index)
1✔
811

812
            ' Find closest focusable item in direction
813
            focusedMetrics = focused.refreshBounding()
1✔
814
            segments = m.collectSegments(focusedMetrics, direction, focusItemsHIDlist, focused.HID)
1✔
815
            if segments.Count() > 0
3✔
816
                return m.findClosestSegment(segments, focusedMetrics.middlePoint)
1✔
817
            end if
818

819
            return ""
×
820
        end function
821

822
        function findClosestSegment(segments as object, middlePoint as object) as string
823
            distances = []
1✔
824

825
            ' Calculate distance from middle point to each segment
826
            for each HID in segments
1✔
827
                segment = segments[HID]
1✔
828
                distance = m.distanceCalculator.distToSegment(middlePoint, {
1✔
829
                    x: segment.x1,
830
                    y: segment.y1
831
                }, {
832
                    x: segment.x2,
833
                    y: segment.y2
834
                })
835

836
                distances.push({
1✔
837
                    HID: HID,
838
                    distance: distance
839
                })
840
            end for
841

842
            ' Find segment with minimum distance
843
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
1✔
844
                return a < b
845
            end function)
846

847
            return minDistItem.HID
1✔
848
        end function
849

850

851
        ' Waterfall of fallback's of groups (linked together with defaultFocusId)
852
        function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
853
            ' Resolve identifier to a group
854
            group = m.groupStack.get(identifier)
1✔
855
            if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
1✔
856
            if group = invalid then return ""
2✔
857

858
            newHID = ""
1✔
859

860
            ' enableSpatialEnter: use spatialNavigation to find closest member
861
            if group.isSpatialEnterEnabledForDirection(direction)
2✔
862
                if direction <> "" and m.globalFocusHID <> ""
3✔
863
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
864
                    if focused <> invalid
3✔
865
                        ' Get group members and use spatial navigation to find closest
866
                        members = group.getGroupMembersHIDs()
1✔
867
                        if members.count() > 0
3✔
868
                            newHID = m.spatialNavigation(focused, direction, members, true)
1✔
869
                            ' If spatial nav found a group (not a focus item), recursively resolve it
870
                            if newHID <> "" and m.groupStack.has(newHID)
2✔
871
                                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
×
872
                            end if
873
                        end if
874
                    end if
875
                end if
876
            end if
877

878
            ' Fallback to getFallbackIdentifier if spatial enter didn't find anything
879
            if newHID = ""
3✔
880
                newHID = group.getFallbackIdentifier(m.globalFocusHID, direction)
1✔
881
            end if
882

883
            ' Check if we found a FocusItem
884
            if m.focusItemStack.has(newHID)
3✔
885
                ' noop — direct focusItem resolved
886
            else if newHID <> ""
3✔
887
                ' Try to find as group first, then deep search
888
                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
1✔
889

890
                ' If still not found, perform deep search in all descendants
891
                if newHID = ""
2✔
892
                    newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
1✔
893
                end if
894
            end if
895

896
            ' Prevent capturing by fallback in the same group where original focus was
897
            ' Skip this guard for enableSpatialEnter groups (spatial enter explicitly targets a sibling group's member)
898
            if not group.isSpatialEnterEnabledForDirection(direction) and newHID <> "" and m.globalFocusHID <> ""
2✔
899
                currentAncestors = m.findAncestorGroups(m.globalFocusHID)
1✔
900
                newAncestors = m.findAncestorGroups(newHID)
1✔
901
                if currentAncestors.Count() > 0 and newAncestors.Count() > 0
3✔
902
                    if currentAncestors[0] = newAncestors[0] and newHID <> m.globalFocusHID then newHID = ""
2✔
903
                end if
904
            end if
905

906
            return newHID
1✔
907
        end function
908

909
        '
910
        ' deepSearchFocusItemByNodeId - Deep search for a FocusItem or Group by nodeId within a group hierarchy
911
        '
912
        ' @param {string} groupHID - The HID of the group to search within
913
        ' @param {string} nodeId - The node ID to search for
914
        ' @returns {string} The HID of the found FocusItem or Group, or empty string if not found
915
        '
916
        function deepSearchFocusItemByNodeId(groupHID as string, nodeId as string) as string
917
            if nodeId = "" then return ""
1✔
918

919
            ' Get all descendants of this group (both FocusItems and nested Groups)
920
            allFocusItems = m.focusItemStack.getAll()
1✔
921
            allGroups = m.groupStack.getAll()
1✔
922

923
            ' First, search in direct and nested FocusItems
924
            for each focusItemHID in allFocusItems
1✔
925
                if Rotor.Utils.isDescendantHID(focusItemHID, groupHID)
3✔
926
                    focusItem = m.focusItemStack.get(focusItemHID)
1✔
927
                    if focusItem <> invalid and focusItem.id = nodeId
2✔
928
                        return focusItemHID
1✔
929
                    end if
930
                end if
931
            end for
932

933
            ' Second, search in nested Groups (and if found, apply fallback logic on that group)
934
            for each nestedGroupHID in allGroups
1✔
935
                if Rotor.Utils.isDescendantHID(nestedGroupHID, groupHID) and nestedGroupHID <> groupHID
3✔
936
                    nestedGroup = m.groupStack.get(nestedGroupHID)
1✔
937
                    if nestedGroup <> invalid and nestedGroup.id = nodeId
2✔
938
                        ' Found a matching group - now apply fallback logic on it
939
                        fallbackHID = nestedGroup.getFallbackIdentifier()
×
940
                        if m.focusItemStack.has(fallbackHID)
×
941
                            return fallbackHID
×
942
                        else if fallbackHID <> ""
×
943
                            ' Recursively resolve the fallback
944
                            return m.capturingFocus_recursively(fallbackHID, "", nestedGroupHID)
×
945
                        end if
946
                    end if
947
                end if
948
            end for
949

950
            return ""
1✔
951
        end function
952

953
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
954
            newHID = ""
1✔
955

956
            ' Build ancestor chain (current group + all ancestors)
957
            ancestorGroups = m.findAncestorGroups(groupHID)
1✔
958
            ancestorGroups.unshift(groupHID)
1✔
959
            ancestorGroupsCount = ancestorGroups.Count()
1✔
960
            ancestorIndex = 0
1✔
961

962
            ' Get currently focused item for spatial navigation
963
            focused = m.focusItemStack.get(m.globalFocusHID)
1✔
964

965
            ' Bubble up through ancestor groups until we find a target or reach the top
966
            while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
1✔
967
                ' Get next ancestor group
968
                groupHID = ancestorGroups[ancestorIndex]
1✔
969
                group = m.groupStack.get(groupHID)
1✔
970

971
                ' Check group's direction configuration
972
                nodeId = group.getStaticNodeIdInDirection(direction)
1✔
973

974
                if Rotor.Utils.isBoolean(nodeId)
2✔
975
                    ' Boolean means focus is explicitly handled
976
                    if nodeId = true
3✔
977
                        newHID = true ' Block navigation (exit loop)
1✔
978
                    else
×
979
                        newHID = "" ' Continue bubbling
×
980
                    end if
981
                else
982
                    ' String nodeId - try to resolve target
3✔
983
                    if nodeId <> ""
3✔
984
                        otherGroup = m.groupStack.getByNodeId(nodeId)
1✔
985
                        if otherGroup <> invalid
3✔
986
                            newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
1✔
987
                        end if
988
                    else
989
                        ' No explicit direction - try spatial navigation at this group level
990
                        ' This allows navigation between sibling child groups with enableSpatialNavigation
991
                        ' Skip during long press to allow bubbling up to parent carousel for continuous scrolling
992
                        ' Skip for "back" direction - spatial navigation doesn't apply
3✔
993
                        if focused <> invalid and m.isLongPress = false and direction <> Rotor.Const.Direction.BACK
3✔
994
                            groupMembers = group.getGroupMembersHIDs()
1✔
995
                            ' Check if this group has any child groups with spatial nav enabled
996
                            hasSpatialNavGroups = false
1✔
997
                            for each memberHID in groupMembers
1✔
998
                                if m.groupStack.has(memberHID)
2✔
999
                                    memberGroup = m.groupStack.get(memberHID)
×
1000
                                    if memberGroup.enableSpatialNavigation = true
×
1001
                                        hasSpatialNavGroups = true
×
1002
                                        exit for
1003
                                    end if
1004
                                end if
1005
                            end for
1006
                            ' If there are spatial-nav-enabled child groups, try spatial navigation
1007
                            ' Use bypassFocusedCheck=true since we're navigating between groups, not within
1008
                            if hasSpatialNavGroups
2✔
1009
                                newHID = m.spatialNavigation(focused, direction, groupMembers, true)
×
1010
                                ' If spatial nav found a group, use capturing focus to enter it
1011
                                if newHID <> "" and m.groupStack.has(newHID)
×
1012
                                    newHID = m.capturingFocus_recursively(newHID, direction)
×
1013
                                end if
1014
                            end if
1015
                        end if
1016
                    end if
1017
                end if
1018

1019
                ancestorIndex++
1✔
1020
            end while
1021

1022
            return newHID
1✔
1023
        end function
1024

1025
        ' * KEY EVENT HANDLER
1026
        function onKeyEventHandler(key as string, press as boolean) as object
1027
            ' Check long-press
1028
            if m.enableLongPressFeature = true
2✔
1029
                m.checkLongPressState(key, press)
1✔
1030
            end if
1031
            ' Prevent any navigation if it is disabled
1032
            #if debug
4✔
1033
            #end if
1034
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
2✔
1035
            ' Execute action according to key press
1036
            return m.executeNavigationAction(key, press)
1✔
1037
        end function
1038

1039
        function executeNavigationAction(key as string, press as boolean) as object
1040

1041
            if true = press
3✔
1042

1043
                ' keyPressHandler: local (no bubbling), fires for all keys before navigation
1044
                focusedItem = m.focusItemStack.get(m.globalFocusHID)
1✔
1045
                if focusedItem <> invalid and focusedItem.keyPressHandler <> invalid
2✔
1046
                    if true = Rotor.Utils.callbackScoped(focusedItem.keyPressHandler, focusedItem.widget, key)
2✔
1047
                        return m.parseOnKeyEventResult(key, true, false)
1✔
1048
                    end if
1049
                end if
1050

1051
                if -1 < Rotor.Utils.findInArray([
2✔
1052
                        Rotor.Const.Direction.UP,
1053
                        Rotor.Const.Direction.RIGHT,
1054
                        Rotor.Const.Direction.DOWN,
1055
                        Rotor.Const.Direction.LEFT,
1056
                        Rotor.Const.Direction.BACK
1057
                    ], key)
1058

1059
                    newHID = ""
1✔
1060
                    direction = key
1✔
1061
                    m.lastNavigationDirection = direction
1✔
1062

1063
                    ' (1) Pick up current focused item
1064

1065
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
1066

1067
                    if focused = invalid
2✔
1068
                        #if debug
×
1069
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue detected. Last known focus id:\"${m.globalFocusHID}\". Please ensure valid focus.`
×
1070
                        #end if
1071
                        return m.parseOnKeyEventResult(key, false, false)
×
1072
                    end if
1073

1074

1075
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
1076
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
1077

1078
                    if ancestorGroupsCount = 0
2✔
1079
                        allFocusItems = m.focusItemStack.getAll()
1✔
1080
                        possibleFocusItems = allFocusItems.keys()
1✔
1081
                        parentGroupHID = ""
1✔
1082
                    else
3✔
1083
                        parentGroupHID = ancestorGroups[0]
1✔
1084
                        group = m.groupStack.get(parentGroupHID)
1✔
1085
                        possibleFocusItems = group.getGroupMembersHIDs()
1✔
1086
                    end if
1087

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

1091
                    if Rotor.Utils.isBoolean(nodeId) and nodeId = true
2✔
1092
                        ' It means that focus is handled, and no need further action by plugin.
1093
                        return m.parseOnKeyEventResult(key, true, false)
×
1094
                    end if
1095

1096
                    if nodeId <> ""
2✔
1097
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
1098
                    end if
1099

1100
                    if newHID = ""
3✔
1101
                        ' (3) Try spatial navigation in direction, among possible focusItems
1102
                        ' all = m.focusItemStack.getAll()
1103
                        ' allKeys = all.Keys()
1104
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
1✔
1105
                    end if
1106

1107
                    ' (4) Check if found group. FocusItem can not point out of group.
1108
                    if newHID = "" and ancestorGroupsCount > 0 ' (5/2) If this focused has parent group, lets try bubbling focus on ancestors (groups)
2✔
1109
                        newHID = m.bubblingFocus(parentGroupHID, direction)
1✔
1110
                        if Rotor.Utils.isBoolean(newHID)
2✔
1111
                            if newHID = true
3✔
1112
                                ' It means that focus is handled, and no need further action by plugin.
1113
                                return m.parseOnKeyEventResult(key, true, false)
1✔
1114
                            else
×
1115
                                newHID = ""
×
1116
                            end if
1117
                        end if
1118
                    end if
1119

1120
                    handled = m.setFocus(newHID)
1✔
1121
                    return m.parseOnKeyEventResult(key, handled, false)
1✔
1122

1123
                else if key = "OK"
2✔
1124

1125
                    return m.parseOnKeyEventResult(key, true, true)
1✔
1126

1127
                end if
1128

1129
                ' Unhandled key (not direction, not OK) → bubble through ancestor groups
1130
                if m.delegateKeyPress(key) then return m.parseOnKeyEventResult(key, true, false)
2✔
1131
            end if
1132

1133
            return m.parseOnKeyEventResult(key, false, false)
1✔
1134

1135
        end function
1136

1137
        function parseOnKeyEventResult(key as string, handled as boolean, isSelected as boolean) as object
1138
            result = {
1✔
1139
                handled: handled,
1140
                key: key
1141
            }
1142
            if m.globalFocusHID <> "" and handled = true
2✔
1143
                focusItem = m.focusItemStack.get(m.globalFocusHID)
1✔
1144
                widget = m.widgetTree.get(focusItem.HID)
1✔
1145
                ' viewModelState = Rotor.Utils.deepCopy(widget.viewModelState)
1146
                result.widget = widget
1✔
1147
                if isSelected
2✔
1148
                    result.isSelected = isSelected
1✔
1149
                    focusItem.callOnSelectFnOnWidget()
1✔
1150
                end if
1151
            end if
1152
            return result
1✔
1153
        end function
1154

1155
        sub checkLongPressState(key as string, press as boolean)
1156
            m.longPressTimer.control = "stop"
1✔
1157
            if press = true
2✔
1158
                if m.isLongPress = false
3✔
1159
                    m.longPressKey = key
1160
                    m.longPressTimer.control = "start"
1✔
1161
                end if
1162
            else
3✔
1163
                wasLongPress = m.isLongPress = true
1✔
1164
                lastKey = m.longPressKey
1✔
1165
                m.isLongPress = false
1✔
1166
                m.longPressKey = ""
1✔
1167
                if wasLongPress
2✔
1168
                    m.delegateLongPressChanged(false, lastKey)
×
1169
                end if
1170
            end if
1171
        end sub
1172

1173
        function proceedLongPress() as object
1174
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(m.longPressKey, false, false)
1✔
1175
            return m.executeNavigationAction(m.longPressKey, true)
1✔
1176
        end function
1177

1178
        ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
1179
        ' @param focusedMetrics - The metrics object from focused.refreshBounding()
1180
        ' @param focusedHID - The HID of the focused item (to exclude from candidates)
1181
        function collectSegments(focusedMetrics as object, direction as string, focusItemsHIDlist as object, focusedHID as string) as object
1182
            refSegments = focusedMetrics.segments
1✔
1183
            refSegmentTop = refSegments[Rotor.Const.Segment.TOP]
1✔
1184
            refSegmentRight = refSegments[Rotor.Const.Segment.RIGHT]
1✔
1185
            refSegmentLeft = refSegments[Rotor.Const.Segment.LEFT]
1✔
1186
            refSegmentBottom = refSegments[Rotor.Const.Segment.BOTTOM]
1✔
1187

1188
            segments = {}
1✔
1189
            validator = m.spatialValidators[direction]
1✔
1190
            for each HID in focusItemsHIDlist
1✔
1191
                if HID <> focusedHID
3✔
1192
                    ' Try to get as FocusItem first, then as Group
1193
                    candidate = m.focusItemStack.get(HID)
1✔
1194
                    isGroup = false
1✔
1195
                    if candidate = invalid
2✔
1196
                        candidate = m.groupStack.get(HID)
×
1197
                        isGroup = true
×
1198
                    end if
1199
                    if candidate = invalid then continue for
2✔
1200

1201
                    ' Skip disabled items - they should not be candidates for spatial navigation
1202
                    if not isGroup and candidate.isEnabled = false then continue for
2✔
1203

1204
                    candidateMetrics = candidate.refreshBounding()
1✔
1205
                    ' Pass appropriate reference segments based on direction
1206
                    if direction = "left" or direction = "right"
3✔
1207
                        result = validator(candidateMetrics.segments, refSegmentLeft, refSegmentRight)
1✔
1208
                    else ' up or down
3✔
1209
                        result = validator(candidateMetrics.segments, refSegmentTop, refSegmentBottom)
1✔
1210
                    end if
1211
                    if result.isValid
3✔
1212
                        segments[HID] = result.segment
1✔
1213
                    end if
1214
                end if
1215
            end for
1216

1217
            return segments
1✔
1218
        end function
1219

1220
        sub destroy()
1221
            ' Remove all groups
1222
            for each HID in m.groupStack.getAll()
1✔
1223
                m.groupStack.remove(HID)
1✔
1224
            end for
1225
            ' Remove all focus items
1226
            for each HID in m.focusItemStack.getAll()
1✔
1227
                m.focusItemStack.remove(HID)
1228
            end for
1229
            m.longPressTimer.unobserveFieldScoped("fire")
1✔
1230
            m.longPressTimer = invalid
1✔
1231
            m.widgetTree = invalid
1✔
1232
        end sub
1233

1234
    end class
1235

1236
    namespace FocusPluginHelper
1237

1238
        class BaseEntryStack extends Rotor.BaseStack
1239

1240
            function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
1241
                if ancestorHID <> "0"
3✔
1242
                    filteredStack = {}
1✔
1243
                    for each HID in m.stack
1✔
1244
                        if Rotor.Utils.isDescendantHID(HID, ancestorHID)
3✔
1245
                            filteredStack[HID] = m.get(HID)
1✔
1246
                        end if
1247
                    end for
1248
                else
3✔
1249
                    filteredStack = m.stack
1✔
1250
                end if
1251
                HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
1✔
1252
                return HID <> "" ? m.get(HID) : invalid
1✔
1253
            end function
1254

1255
            override sub remove(HID as string)
1256
                item = m.get(HID)
1✔
1257
                item.destroy()
1✔
1258
                super.remove(HID)
1✔
1259
            end sub
1260

1261
        end class
1262

1263
        class GroupStack extends BaseEntryStack
1264

1265
            function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1266
                foundHID = ""
×
1267
                for each HID in possibleGroups
×
1268
                    group = m.get(HID)
×
1269
                    if group.id = nodeId
×
1270
                        foundHID = group.HID
×
1271
                        exit for
1272
                    end if
1273
                end for
1274
                return foundHID
×
1275
            end function
1276

1277
        end class
1278

1279

1280
        class FocusItemStack extends BaseEntryStack
1281

1282
            function convertNodeIdToHID(nodeId as string, possibleFocusItems as object) as string
1283
                foundHID = ""
×
1284
                for each HID in possibleFocusItems
×
1285
                    focusItem = m.get(HID)
×
1286
                    if focusItem?.id = nodeId
×
1287
                        foundHID = focusItem.HID
×
1288
                        exit for
1289
                    end if
1290
                end for
1291
                return foundHID
×
1292
            end function
1293

1294
            function hasEnabled(HID as string) as boolean
1295
                if m.has(HID)
3✔
1296
                    focusItem = m.get(HID)
1✔
1297
                    return focusItem.isEnabled
1✔
1298
                else
3✔
1299
                    return false
1✔
1300
                end if
1301
            end function
1302

1303
        end class
1304

1305
        class BaseFocusConfig
1306

1307
            staticDirection as object
1308

1309
            sub new (config as object)
1310

1311
                m.HID = config.HID
1✔
1312
                m.id = config.id
1✔
1313

1314
                m.widget = config.widget
1✔
1315
                m.node = m.widget.node
1✔
1316
                m.isFocused = config.isFocused ?? false
1✔
1317

1318
                m.isEnabled = config.isEnabled ?? true
1✔
1319
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? false
1✔
1320
                m.staticDirection = {}
1✔
1321
                m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1✔
1322
                m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
1✔
1323
                m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
1✔
1324
                m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
1✔
1325
                m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
1✔
1326

1327
                m.onFocusChanged = config.onFocusChanged
1✔
1328
                m.longPressHandler = config.longPressHandler
1✔
1329
                m.keyPressHandler = config.keyPressHandler
1✔
1330
                m.onFocus = config.onFocus
1✔
1331
                m.onBlur = config.onBlur
1✔
1332

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

1335
                if m.widget.isViewModel = true and not m.widget.viewModelState.DoesExist("isFocused")
2✔
1336
                    m.widget.viewModelState.isFocused = false
×
1337
                end if
1338

1339
            end sub
1340

1341

1342
            HID as string
1343
            id as string
1344
            idByKeys as object
1345
            isEnabled as boolean
1346
            isFocused as boolean
1347
            enableSpatialNavigation as boolean
1348
            onFocusChanged as dynamic
1349
            onFocus as dynamic
1350
            onBlur as dynamic
1351
            longPressHandler as dynamic
1352
            keyPressHandler as dynamic
1353
            node as object
1354
            widget as object
1355

1356
            protected metrics = {
1357
                segments: {}
1358
            }
1359

1360
            function refreshBounding() as object
1361
                b = m.node.sceneBoundingRect()
×
1362
                rotation = m.node.rotation
×
1363

1364
                if rotation = 0
×
1365
                    if b.y = 0 and b.x = 0
×
1366
                        t = m.node.translation
×
1367
                        b.x += t[0]
×
1368
                        b.y += t[1]
×
1369
                    end if
1370

1371
                    m.metrics.append(b)
×
1372
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
×
1373
                        x1: b.x, y1: b.y,
1374
                        x2: b.x, y2: b.y + b.height
1375
                    }
1376
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
×
1377
                        x1: b.x, y1: b.y,
1378
                        x2: b.x + b.width, y2: b.y
1379
                    }
1380
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
×
1381
                        x1: b.x + b.width, y1: b.y,
1382
                        x2: b.x + b.width, y2: b.y + b.height
1383
                    }
1384
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
×
1385
                        x1: b.x, y1: b.y + b.height,
1386
                        x2: b.x + b.width, y2: b.y + b.height
1387
                    }
1388
                    m.metrics.middlePoint = {
×
1389
                        x: b.x + b.width / 2,
1390
                        y: b.y + b.height / 2
1391
                    }
1392
                end if
1393

1394
                return m.metrics
×
1395
            end function
1396

1397
            function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1398
                direction = m.staticDirection[direction]
1✔
1399
                if Rotor.Utils.isFunction(direction)
2✔
1400
                    return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
×
1401
                else
3✔
1402
                    return direction ?? ""
1✔
1403
                end if
1404
            end function
1405

1406
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1407
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1408
                if true = isFocused
3✔
1409
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1410
                else
3✔
1411
                    Rotor.Utils.callbackScoped(m.onBlur, m.widget)
1✔
1412
                end if
1413
            end sub
1414

1415
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1416
                if Rotor.Utils.isFunction(m.longPressHandler)
2✔
1417
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1418
                else
3✔
1419
                    return false
1✔
1420
                end if
1421
            end function
1422

1423
            function callKeyPressHandler(key as string) as boolean
1424
                if Rotor.Utils.isFunction(m.keyPressHandler)
2✔
1425
                    return Rotor.Utils.callbackScoped(m.keyPressHandler, m.widget, key)
1✔
1426
                else
3✔
1427
                    return false
1✔
1428
                end if
1429
            end function
1430

1431
            sub destroy()
1432
                m.widget = invalid
1✔
1433
                m.node = invalid
1✔
1434
                m.onFocusChanged = invalid
1✔
1435
                m.onFocus = invalid
1✔
1436
                m.onBlur = invalid
1✔
1437
                m.longPressHandler = invalid
1✔
1438
                m.keyPressHandler = invalid
1✔
1439
            end sub
1440

1441
        end class
1442

1443
        class GroupClass extends BaseFocusConfig
1444
            ' Note: Spatial navigation is supported within group, there is no spatial navigation between groups
1445
            ' If you want to focus out to another group, you need to config a direction prop.
1446
            ' You can set a groupId or any focusItem widgetId.
1447
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1448
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1449

1450
            sub new (config as object)
1451
                super(config)
1✔
1452
                m.defaultFocusId = config.defaultFocusId ?? ""
1✔
1453
                m.lastFocusedHID = config.lastFocusedHID ?? ""
1✔
1454
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
1✔
1455
                m.enableLastFocusId = config.enableLastFocusId ?? true
1✔
1456
                m.enableDeepLastFocusId = config.enableDeepLastFocusId ?? false
1✔
1457
            end sub
1458

1459
            defaultFocusId as string
1460
            lastFocusedHID as string
1461
            enableSpatialEnter as dynamic ' boolean | { up?: boolean, down?: boolean, left?: boolean, right?: boolean }
1462
            enableLastFocusId as boolean
1463
            enableDeepLastFocusId as boolean
1464

1465
            '
1466
            ' isSpatialEnterEnabledForDirection - Checks if spatial enter is enabled for a specific direction
1467
            '
1468
            ' @param {string} direction - The direction to check (up, down, left, right)
1469
            ' @returns {boolean} True if spatial enter is enabled for the direction
1470
            '
1471
            function isSpatialEnterEnabledForDirection(direction as string) as boolean
1472
                if Rotor.Utils.isBoolean(m.enableSpatialEnter)
3✔
1473
                    return m.enableSpatialEnter
1✔
1474
                else if Rotor.Utils.isAssociativeArray(m.enableSpatialEnter)
3✔
1475
                    return m.enableSpatialEnter[direction] = true
1✔
1476
                end if
1477
                return false
×
1478
            end function
1479
            focusItemsRef as object
1480
            groupsRef as object
1481
            isFocusItem = false
1482
            isGroup = true
1483

1484
            sub setLastFocusedHID(lastFocusedHID as string)
1485
                m.lastFocusedHID = lastFocusedHID
1✔
1486
            end sub
1487

1488
            function getGroupMembersHIDs()
1489
                ' Collect all focusItems that are descendants of this group
1490
                ' Exclude items that belong to nested sub-groups
1491
                ' Also include direct child groups with enableSpatialNavigation: true
1492
                focusItems = m.focusItemsRef.getAll()
1✔
1493
                groups = m.groupsRef.getAll()
1✔
1494
                HIDlen = Len(m.HID)
1✔
1495
                collection = []
1✔
1496
                groupsKeys = groups.keys()
1✔
1497
                groupsCount = groups.Count()
1✔
1498

1499
                ' Collect focusItems (existing logic)
1500
                for each focusItemHID in focusItems
1✔
1501
                    ' Check if focusItem is a descendant of this group
1502
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
1✔
1503
                    if isDescendant
2✔
1504
                        ' Check if focusItem belongs to a nested sub-group
1505
                        shouldExclude = false
1✔
1506
                        otherGroupIndex = 0
1✔
1507
                        while shouldExclude = false and otherGroupIndex < groupsCount
1✔
1508
                            otherGroupHID = groupsKeys[otherGroupIndex]
1✔
1509
                            otherGroupHIDlen = Len(otherGroupHID)
1✔
1510
                            ' Exclude if belongs to deeper nested group
1511
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
1✔
1512
                            otherGroupIndex++
1✔
1513
                        end while
1514

1515
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1516
                    end if
1517
                end for
1518

1519
                ' Collect direct child groups with enableSpatialNavigation: true
1520
                for i = 0 to groupsCount - 1
1✔
1521
                    childGroupHID = groupsKeys[i]
1✔
1522
                    childGroupHIDlen = Len(childGroupHID)
1✔
1523

1524
                    ' Check if it's a descendant of this group (but not this group itself)
1525
                    if childGroupHIDlen > HIDlen and Left(childGroupHID, HIDlen) = m.HID
2✔
1526
                        childGroup = groups[childGroupHID]
1✔
1527

1528
                        ' Only include if enableSpatialNavigation is true
1529
                        if childGroup.enableSpatialNavigation = true
2✔
1530
                            ' Check if it's a DIRECT child (no intermediate groups)
1531
                            isDirectChild = true
×
1532
                            for j = 0 to groupsCount - 1
×
1533
                                intermediateHID = groupsKeys[j]
×
1534
                                intermediateLen = Len(intermediateHID)
×
1535
                                ' Check if there's a group between this group and the child
1536
                                if intermediateLen > HIDlen and intermediateLen < childGroupHIDlen
×
1537
                                    if Left(childGroupHID, intermediateLen) = intermediateHID
×
1538
                                        isDirectChild = false
×
1539
                                        exit for
1540
                                    end if
1541
                                end if
1542
                            end for
1543

1544
                            if isDirectChild then collection.push(childGroupHID)
×
1545
                        end if
1546
                    end if
1547
                end for
1548

1549
                return collection
1✔
1550
            end function
1551

1552
            '
1553
            ' getFallbackNodeId - Returns the nodeId to use for fallback (defaultFocusId or lastFocusedHID)
1554
            '
1555
            ' @returns {string} The nodeId to use for fallback, or empty string if none
1556
            '
1557
            function getFallbackNodeId() as string
1558
                if m.lastFocusedHID <> ""
2✔
1559
                    ' Note: lastFocusedHID is already a HID, not a nodeId, so we need to get the nodeId
1560
                    lastFocusedItem = m.focusItemsRef.get(m.lastFocusedHID)
×
1561
                    if lastFocusedItem <> invalid
×
1562
                        return lastFocusedItem.id
×
1563
                    end if
1564
                end if
1565

1566
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1567
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1568
                else
3✔
1569
                    return m.defaultFocusId
1✔
1570
                end if
1571
            end function
1572

1573
            function getFallbackIdentifier(globalFocusHID = "" as string, direction = "" as string) as string
1574
                HID = ""
1✔
1575
                ' enableSpatialEnter is handled by capturingFocus_recursively using spatialNavigation
1576
                ' Here we only handle lastFocusedHID and defaultFocusId fallbacks
1577

1578
                ' Use lastFocusedHID if available AND still exists (check both focusItems and groups)
1579
                if not m.isSpatialEnterEnabledForDirection(direction) and m.lastFocusedHID <> ""
2✔
1580
                    if m.focusItemsRef.has(m.lastFocusedHID) or m.groupsRef.has(m.lastFocusedHID)
3✔
1581
                        return m.lastFocusedHID
1✔
1582
                    end if
1583
                end if
1584

1585
                ' Default: use defaultFocusId expression
1586
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1587
                    defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
1✔
1588
                else
3✔
1589
                    defaultFocusId = m.defaultFocusId
1✔
1590
                end if
1591

1592
                focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1593

1594
                if defaultFocusId <> ""
3✔
1595
                    if focusItemsHIDlist.Count() > 0
3✔
1596
                        ' Try find valid HID in focusItems by node id
1597
                        focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1✔
1598
                        if focusItemHID <> ""
3✔
1599
                            return focusItemHID
1✔
1600
                        end if
1601
                    end if
1602
                    ' If not found as focusItem, return defaultFocusId string
1603
                    ' so capturingFocus_recursively can try to resolve it as a group
1604
                    return defaultFocusId
1✔
1605
                end if
1606

1607
                ' Last resort: pick the first available member of the group
1608
                if focusItemsHIDlist.Count() > 0
3✔
1609
                    return focusItemsHIDlist[0]
1✔
1610
                end if
1611

1612
                return HID
×
1613
            end function
1614

1615
            function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1616
                for each itemHID in focusItemsHIDlist
1✔
1617
                    focusItem = m.focusItemsRef.get(itemHID)
1✔
1618
                    if focusItem <> invalid and focusItem.id = nodeId
3✔
1619
                        return focusItem.HID
1✔
1620
                    end if
1621
                end for
1622
                return ""
×
1623
            end function
1624

1625
            sub applyFocus(isFocused as boolean)
1626
                if m.isFocused = isFocused then return
2✔
1627

1628
                m.isFocused = isFocused
1✔
1629
                m.node.setField("isFocused", isFocused)
1✔
1630
                if m.widget.isViewModel = true then m.widget.viewModelState.isFocused = isFocused
2✔
1631
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1632
            end sub
1633

1634
            override sub destroy()
1635
                super.destroy()
1✔
1636
                m.focusItemsRef = invalid
1✔
1637
                m.groupsRef = invalid
1✔
1638
            end sub
1639

1640

1641

1642
        end class
1643

1644
        class FocusItemClass extends BaseFocusConfig
1645

1646
            sub new (config as object)
1647
                super(config)
1✔
1648

1649
                m.onSelect = config.onSelect ?? config.ok ?? ""
1✔
1650
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1651
            end sub
1652

1653
            ' You can set a groupId or any focusItem widgetId.
1654
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1655
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1656

1657
            ' key as string
1658
            isFocusItem = true
1659
            isGroup = false
1660
            enableNativeFocus as boolean
1661
            onSelect as dynamic
1662

1663
            private bounding as object
1664

1665

1666
            override function refreshBounding() as object
1667
                b = m.node.sceneBoundingRect()
1✔
1668
                rotation = m.node.rotation
1✔
1669

1670
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1671
                ' That is why we can use translation without knowing the value of inheritParentTransform
1672
                ' If bounding x or y are not zero, then bounding will include the node's translation
1673
                if rotation = 0
3✔
1674
                    if b.y = 0 and b.x = 0
2✔
1675
                        t = m.node.translation
1✔
1676
                        b.x += t[0]
1✔
1677
                        b.y += t[1]
1✔
1678
                    end if
1679

1680
                    m.metrics.append(b)
1✔
1681
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
1✔
1682
                        x1: b.x, y1: b.y,
1683
                        x2: b.x, y2: b.y + b.height
1684
                    }
1685
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
1✔
1686
                        x1: b.x, y1: b.y,
1687
                        x2: b.x + b.width, y2: b.y
1688
                    }
1689
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
1✔
1690
                        x1: b.x + b.width, y1: b.y,
1691
                        x2: b.x + b.width, y2: b.y + b.height
1692
                    }
1693
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
1✔
1694
                        x1: b.x, y1: b.y + b.height,
1695
                        x2: b.x + b.width, y2: b.y + b.height
1696
                    }
1697
                    m.metrics.middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
1✔
1698
                else
1699
                    scaleRotateCenter = m.node.scaleRotateCenter
×
1700
                    dims = m.node.localBoundingRect() ' We need this to get proper (rotated value of rotated x and y)
×
1701
                    if b.y = 0 and b.x = 0
×
1702
                        t = m.node.translation
×
1703
                        b.x += t[0]
×
1704
                        b.y += t[1]
×
1705
                    end if
1706
                    b.width = dims.width
×
1707
                    b.height = dims.height
×
1708
                    m.metrics.append(b)
×
1709

1710
                    ' Calculate rotated segments
1711
                    segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
×
1712
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1713
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
×
1714

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

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

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

1727
                    ' Calculate rotated middle point
1728
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1729
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1730
                    m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
×
1731

1732
                end if
1733

1734
                return m.metrics
1✔
1735
            end function
1736

1737
            override sub destroy()
1738
                m.onSelect = invalid
1✔
1739
                m.metrics.segments.Clear()
1✔
1740
                super.destroy()
1✔
1741
            end sub
1742

1743
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1744
                if m.isFocused = isFocused then return
2✔
1745

1746
                m.isFocused = isFocused
1✔
1747
                m.node.setField("isFocused", isFocused)
1✔
1748
                if m.widget.isViewModel = true then m.widget.viewModelState.isFocused = isFocused
2✔
1749

1750
                if enableNativeFocus or m.enableNativeFocus
2✔
1751
                    m.node.setFocus(isFocused)
1✔
1752
                end if
1753

1754
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1755

1756
            end sub
1757

1758
            sub callOnSelectFnOnWidget()
1759
                Rotor.Utils.callbackScoped(m.onSelect, m.widget)
1✔
1760
            end sub
1761

1762
        end class
1763

1764
        class ClosestSegmentToPointCalculatorClass
1765

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

1769
                A = x - x1
1✔
1770
                B = y - y1
1✔
1771
                C = x2 - x1
1✔
1772
                D = y2 - y1
1✔
1773

1774
                dot = A * C + B * D
1✔
1775
                len_sq = C * C + D * D
1✔
1776
                param = -1
1✔
1777
                if len_sq <> 0
3✔
1778
                    param = dot / len_sq
1✔
1779
                end if
1780

1781
                xx = 0
1✔
1782
                yy = 0
1✔
1783

1784
                if param < 0
2✔
1785
                    xx = x1
1✔
1786
                    yy = y1
1✔
1787
                else if param > 1
2✔
1788
                    xx = x2
1✔
1789
                    yy = y2
1✔
1790
                else
3✔
1791
                    xx = x1 + param * C
1✔
1792
                    yy = y1 + param * D
1✔
1793
                end if
1794

1795
                dx = x - xx
1✔
1796
                dy = y - yy
1✔
1797
                return dx * dx + dy * dy
1✔
1798
            end function
1799

1800
            function distToSegment(p as object, s1 as object, s2 as object)
1801
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1802
            end function
1803

1804
        end class
1805

1806
    end namespace
1807

1808
    namespace FocusPluginHelper
1809

1810
        sub longPressObserverCallback(msg)
1811
            extraInfo = msg.GetInfo()
1✔
1812

1813
            pluginKey = extraInfo["pluginKey"]
1✔
1814

1815
            globalScope = GetGlobalAA()
1✔
1816
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
1✔
1817
            plugin = frameworkInstance.plugins[pluginKey]
1✔
1818
            plugin.isLongPress = true
1✔
1819
            ' plugin.longPressStartHID = plugin.globalFocusHID
1820
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
1✔
1821

1822
        end sub
1823

1824
    end namespace
1825

1826
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