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

mobalazs / rotor-framework / 22236823415

20 Feb 2026 06:48PM UTC coverage: 90.183% (+0.04%) from 90.147%
22236823415

push

github

mobalazs
feat: implement keyPressHandler for non-navigation key delegation in focus plugin

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

2 existing lines in 2 files now uncovered.

2223 of 2465 relevant lines covered (90.18%)

1.26 hits per line

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

84.56
/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
1✔
249
                scope.removeFocusConfig(widget.HID)
1✔
250
                if hadFocus
2✔
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
1✔
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
2✔
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
            handled = focused.callKeyPressHandler(key)
1✔
784
            if handled then return true
2✔
785

786
            ' Bubble through ancestor groups
787
            focusChainGroups = m.findAncestorGroups(focused.HID)
1✔
788
            for each groupHID in focusChainGroups
1✔
789
                group = m.groupStack.get(groupHID)
1✔
790
                handled = group.callKeyPressHandler(key)
1✔
791
                if handled then return true
2✔
792
            end for
793

794
            return false
1✔
795
        end function
796

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

802
            focusChainGroups = m.findAncestorGroups(focused.HID)
1✔
803
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
1✔
804
        end sub
805

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

811
            ' Remove current focused item from candidates
812
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
1✔
813
            if index >= 0 then focusItemsHIDlist.delete(index)
2✔
814

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

822
            return ""
×
823
        end function
824

825
        function findClosestSegment(segments as object, middlePoint as object) as string
826
            distances = []
1✔
827

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

839
                distances.push({
1✔
840
                    HID: HID,
841
                    distance: distance
842
                })
843
            end for
844

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

850
            return minDistItem.HID
1✔
851
        end function
852

853

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

861
            newHID = ""
1✔
862

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

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

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

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

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

909
            return newHID
1✔
910
        end function
911

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

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

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

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

953
            return ""
1✔
954
        end function
955

956
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
957
            newHID = ""
1✔
958

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

965
            ' Get currently focused item for spatial navigation
966
            focused = m.focusItemStack.get(m.globalFocusHID)
1✔
967

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

974
                ' Check group's direction configuration
975
                nodeId = group.getStaticNodeIdInDirection(direction)
1✔
976

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

1022
                ancestorIndex++
1✔
1023
            end while
1024

1025
            return newHID
1✔
1026
        end function
1027

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

1042
        function executeNavigationAction(key as string, press as boolean) as object
1043

1044
            if true = press
3✔
1045

1046
                if -1 < Rotor.Utils.findInArray([
2✔
1047
                        Rotor.Const.Direction.UP,
1048
                        Rotor.Const.Direction.RIGHT,
1049
                        Rotor.Const.Direction.DOWN,
1050
                        Rotor.Const.Direction.LEFT,
1051
                        Rotor.Const.Direction.BACK
1052
                    ], key)
1053

1054
                    newHID = ""
1✔
1055
                    direction = key
1✔
1056
                    m.lastNavigationDirection = direction
1✔
1057

1058
                    ' (1) Pick up current focused item
1059

1060
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
1061

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

1069

1070
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
1071
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
1072

1073
                    if ancestorGroupsCount = 0
2✔
1074
                        allFocusItems = m.focusItemStack.getAll()
1✔
1075
                        possibleFocusItems = allFocusItems.keys()
1✔
1076
                        parentGroupHID = ""
1✔
1077
                    else
3✔
1078
                        parentGroupHID = ancestorGroups[0]
1✔
1079
                        group = m.groupStack.get(parentGroupHID)
1✔
1080
                        possibleFocusItems = group.getGroupMembersHIDs()
1✔
1081
                    end if
1082

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

1086
                    if Rotor.Utils.isBoolean(nodeId) and nodeId = true
2✔
1087
                        ' It means that focus is handled, and no need further action by plugin.
1088
                        return m.parseOnKeyEventResult(key, true, false)
×
1089
                    end if
1090

1091
                    if nodeId <> ""
2✔
1092
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
1093
                    end if
1094

1095
                    if newHID = ""
3✔
1096
                        ' (3) Try spatial navigation in direction, among possible focusItems
1097
                        ' all = m.focusItemStack.getAll()
1098
                        ' allKeys = all.Keys()
1099
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
1✔
1100
                    end if
1101

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

1115
                    handled = m.setFocus(newHID)
1✔
1116
                    return m.parseOnKeyEventResult(key, handled, false)
1✔
1117

1118
                else if key = "OK"
3✔
1119

1120
                    return m.parseOnKeyEventResult(key, true, true)
1✔
1121

1122
                else
1123
                    ' Non-navigation, non-OK key → delegate to keyPressHandler
3✔
1124
                    handled = m.delegateKeyPress(key)
1✔
1125
                    return m.parseOnKeyEventResult(key, handled, false)
1✔
1126

1127
                end if
1128
            end if
1129

UNCOV
1130
            return m.parseOnKeyEventResult(key, false, false)
×
1131

1132
        end function
1133

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

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

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

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

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

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

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

1214
            return segments
1✔
1215
        end function
1216

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

1231
    end class
1232

1233
    namespace FocusPluginHelper
1234

1235
        class BaseEntryStack extends Rotor.BaseStack
1236

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

1252
            override sub remove(HID as string)
1253
                item = m.get(HID)
1✔
1254
                item.destroy()
1✔
1255
                super.remove(HID)
1✔
1256
            end sub
1257

1258
        end class
1259

1260
        class GroupStack extends BaseEntryStack
1261

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

1274
        end class
1275

1276

1277
        class FocusItemStack extends BaseEntryStack
1278

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

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

1300
        end class
1301

1302
        class BaseFocusConfig
1303

1304
            staticDirection as object
1305

1306
            sub new (config as object)
1307

1308
                m.HID = config.HID
1✔
1309
                m.id = config.id
1✔
1310

1311
                m.widget = config.widget
1✔
1312
                m.node = m.widget.node
1✔
1313
                m.isFocused = config.isFocused ?? false
1✔
1314

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

1324
                m.onFocusChanged = config.onFocusChanged
1✔
1325
                m.longPressHandler = config.longPressHandler
1✔
1326
                m.keyPressHandler = config.keyPressHandler
1✔
1327
                m.onFocus = config.onFocus
1✔
1328
                m.onBlur = config.onBlur
1✔
1329

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

1332
                if m.widget.isViewModel = true and not m.widget.viewModelState.DoesExist("isFocused")
2✔
1333
                    m.widget.viewModelState.isFocused = false
×
1334
                end if
1335

1336
            end sub
1337

1338

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

1353
            protected metrics = {
1354
                segments: {}
1355
            }
1356

1357
            function refreshBounding() as object
1358
                b = m.node.sceneBoundingRect()
×
1359
                rotation = m.node.rotation
×
1360

1361
                if rotation = 0
×
1362
                    if b.y = 0 and b.x = 0
×
1363
                        t = m.node.translation
×
1364
                        b.x += t[0]
×
1365
                        b.y += t[1]
×
1366
                    end if
1367

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

1391
                return m.metrics
×
1392
            end function
1393

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

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

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

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

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

1438
        end class
1439

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

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

1456
            defaultFocusId as string
1457
            lastFocusedHID as string
1458
            enableSpatialEnter as dynamic ' boolean | { up?: boolean, down?: boolean, left?: boolean, right?: boolean }
1459
            enableLastFocusId as boolean
1460
            enableDeepLastFocusId as boolean
1461

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

1481
            sub setLastFocusedHID(lastFocusedHID as string)
1482
                m.lastFocusedHID = lastFocusedHID
1✔
1483
            end sub
1484

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

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

1512
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1513
                    end if
1514
                end for
1515

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

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

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

1541
                            if isDirectChild then collection.push(childGroupHID)
×
1542
                        end if
1543
                    end if
1544
                end for
1545

1546
                return collection
1✔
1547
            end function
1548

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

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

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

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

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

1589
                focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1590

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

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

1609
                return HID
×
1610
            end function
1611

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

1622
            sub applyFocus(isFocused as boolean)
1623
                if m.isFocused = isFocused then return
2✔
1624

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

1631
            override sub destroy()
1632
                super.destroy()
1✔
1633
                m.focusItemsRef = invalid
1✔
1634
                m.groupsRef = invalid
1✔
1635
            end sub
1636

1637

1638

1639
        end class
1640

1641
        class FocusItemClass extends BaseFocusConfig
1642

1643
            sub new (config as object)
1644
                super(config)
1✔
1645

1646
                m.onSelect = config.onSelect ?? ""
1✔
1647
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1648
            end sub
1649

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

1654
            ' key as string
1655
            isFocusItem = true
1656
            isGroup = false
1657
            enableNativeFocus as boolean
1658
            onSelect as dynamic
1659

1660
            private bounding as object
1661

1662

1663
            override function refreshBounding() as object
1664
                b = m.node.sceneBoundingRect()
1✔
1665
                rotation = m.node.rotation
1✔
1666

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

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

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

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

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

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

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

1729
                end if
1730

1731
                return m.metrics
1✔
1732
            end function
1733

1734
            override sub destroy()
1735
                m.onSelect = invalid
1✔
1736
                m.metrics.segments.Clear()
1✔
1737
                super.destroy()
1✔
1738
            end sub
1739

1740
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1741
                if m.isFocused = isFocused then return
2✔
1742

1743
                m.isFocused = isFocused
1✔
1744
                m.node.setField("isFocused", isFocused)
1✔
1745
                if m.widget.isViewModel = true then m.widget.viewModelState.isFocused = isFocused
2✔
1746

1747
                if enableNativeFocus or m.enableNativeFocus
2✔
1748
                    m.node.setFocus(isFocused)
1✔
1749
                end if
1750

1751
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1752

1753
            end sub
1754

1755
            sub callOnSelectFnOnWidget()
1756
                Rotor.Utils.callbackScoped(m.onSelect, m.widget)
1✔
1757
            end sub
1758

1759
        end class
1760

1761
        class ClosestSegmentToPointCalculatorClass
1762

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

1766
                A = x - x1
1✔
1767
                B = y - y1
1✔
1768
                C = x2 - x1
1✔
1769
                D = y2 - y1
1✔
1770

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

1778
                xx = 0
1✔
1779
                yy = 0
1✔
1780

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

1792
                dx = x - xx
1✔
1793
                dy = y - yy
1✔
1794
                return dx * dx + dy * dy
1✔
1795
            end function
1796

1797
            function distToSegment(p as object, s1 as object, s2 as object)
1798
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1799
            end function
1800

1801
        end class
1802

1803
    end namespace
1804

1805
    namespace FocusPluginHelper
1806

1807
        sub longPressObserverCallback(msg)
1808
            extraInfo = msg.GetInfo()
1✔
1809

1810
            pluginKey = extraInfo["pluginKey"]
1✔
1811

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

1819
        end sub
1820

1821
    end namespace
1822

1823
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