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

mobalazs / rotor-framework / 22230562974

20 Feb 2026 03:43PM UTC coverage: 90.147% (+0.06%) from 90.086%
22230562974

push

github

mobalazs
docs: enhance focus plugin documentation to clarify child member fallback behavior and best practices

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

1 existing line in 1 file now uncovered.

2205 of 2446 relevant lines covered (90.15%)

1.26 hits per line

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

84.09
/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
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
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)
2✔
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
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
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
780
            focused = m.getFocusedItem()
1✔
781
            handled = focused.callLongPressHandler(isLongPress, key)
1✔
782
            if handled then return
2✔
783

784
            focusChainGroups = m.findAncestorGroups(focused.HID)
1✔
785
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
1✔
786
        end sub
787

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

793
            ' Remove current focused item from candidates
794
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
1✔
795
            if index >= 0 then focusItemsHIDlist.delete(index)
2✔
796

797
            ' Find closest focusable item in direction
798
            focusedMetrics = focused.refreshBounding()
1✔
799
            segments = m.collectSegments(focusedMetrics, direction, focusItemsHIDlist, focused.HID)
1✔
800
            if segments.Count() > 0
3✔
801
                return m.findClosestSegment(segments, focusedMetrics.middlePoint)
1✔
802
            end if
803

804
            return ""
×
805
        end function
806

807
        function findClosestSegment(segments as object, middlePoint as object) as string
808
            distances = []
1✔
809

810
            ' Calculate distance from middle point to each segment
811
            for each HID in segments
1✔
812
                segment = segments[HID]
1✔
813
                distance = m.distanceCalculator.distToSegment(middlePoint, {
1✔
814
                    x: segment.x1,
815
                    y: segment.y1
816
                }, {
817
                    x: segment.x2,
818
                    y: segment.y2
819
                })
820

821
                distances.push({
1✔
822
                    HID: HID,
823
                    distance: distance
824
                })
825
            end for
826

827
            ' Find segment with minimum distance
828
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
1✔
829
                return a < b
830
            end function)
831

832
            return minDistItem.HID
1✔
833
        end function
834

835

836
        ' Waterfall of fallback's of groups (linked together with defaultFocusId)
837
        function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
838
            ' Resolve identifier to a group
839
            group = m.groupStack.get(identifier)
1✔
840
            if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
2✔
841
            if group = invalid then return ""
2✔
842

843
            newHID = ""
1✔
844

845
            ' enableSpatialEnter: use spatialNavigation to find closest member
846
            if group.isSpatialEnterEnabledForDirection(direction)
2✔
847
                if direction <> "" and m.globalFocusHID <> ""
3✔
848
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
849
                    if focused <> invalid
3✔
850
                        ' Get group members and use spatial navigation to find closest
851
                        members = group.getGroupMembersHIDs()
1✔
852
                        if members.count() > 0
3✔
853
                            newHID = m.spatialNavigation(focused, direction, members, true)
1✔
854
                            ' If spatial nav found a group (not a focus item), recursively resolve it
855
                            if newHID <> "" and m.groupStack.has(newHID)
2✔
856
                                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
×
857
                            end if
858
                        end if
859
                    end if
860
                end if
861
            end if
862

863
            ' Fallback to getFallbackIdentifier if spatial enter didn't find anything
864
            if newHID = ""
3✔
865
                newHID = group.getFallbackIdentifier(m.globalFocusHID, direction)
1✔
866
            end if
867

868
            ' Check if we found a FocusItem
869
            if m.focusItemStack.has(newHID)
3✔
870
                ' noop — direct focusItem resolved
871
            else if newHID <> ""
3✔
872
                ' Try to find as group first, then deep search
873
                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
1✔
874

875
                ' If still not found, perform deep search in all descendants
876
                if newHID = ""
2✔
877
                    newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
1✔
878
                end if
879
            end if
880

881
            ' Prevent capturing by fallback in the same group where original focus was
882
            ' Skip this guard for enableSpatialEnter groups (spatial enter explicitly targets a sibling group's member)
883
            if not group.isSpatialEnterEnabledForDirection(direction) and newHID <> "" and m.globalFocusHID <> ""
2✔
884
                currentAncestors = m.findAncestorGroups(m.globalFocusHID)
1✔
885
                newAncestors = m.findAncestorGroups(newHID)
1✔
886
                if currentAncestors.Count() > 0 and newAncestors.Count() > 0
3✔
887
                    if currentAncestors[0] = newAncestors[0] then newHID = ""
2✔
888
                end if
889
            end if
890

891
            return newHID
1✔
892
        end function
893

894
        '
895
        ' deepSearchFocusItemByNodeId - Deep search for a FocusItem or Group by nodeId within a group hierarchy
896
        '
897
        ' @param {string} groupHID - The HID of the group to search within
898
        ' @param {string} nodeId - The node ID to search for
899
        ' @returns {string} The HID of the found FocusItem or Group, or empty string if not found
900
        '
901
        function deepSearchFocusItemByNodeId(groupHID as string, nodeId as string) as string
902
            if nodeId = "" then return ""
1✔
903

904
            ' Get all descendants of this group (both FocusItems and nested Groups)
905
            allFocusItems = m.focusItemStack.getAll()
1✔
906
            allGroups = m.groupStack.getAll()
1✔
907

908
            ' First, search in direct and nested FocusItems
909
            for each focusItemHID in allFocusItems
1✔
910
                if Rotor.Utils.isDescendantHID(focusItemHID, groupHID)
3✔
911
                    focusItem = m.focusItemStack.get(focusItemHID)
1✔
912
                    if focusItem <> invalid and focusItem.id = nodeId
2✔
913
                        return focusItemHID
1✔
914
                    end if
915
                end if
916
            end for
917

918
            ' Second, search in nested Groups (and if found, apply fallback logic on that group)
919
            for each nestedGroupHID in allGroups
1✔
920
                if Rotor.Utils.isDescendantHID(nestedGroupHID, groupHID) and nestedGroupHID <> groupHID
3✔
921
                    nestedGroup = m.groupStack.get(nestedGroupHID)
1✔
922
                    if nestedGroup <> invalid and nestedGroup.id = nodeId
2✔
923
                        ' Found a matching group - now apply fallback logic on it
924
                        fallbackHID = nestedGroup.getFallbackIdentifier()
1✔
925
                        if m.focusItemStack.has(fallbackHID)
3✔
926
                            return fallbackHID
1✔
927
                        else if fallbackHID <> ""
×
928
                            ' Recursively resolve the fallback
929
                            return m.capturingFocus_recursively(fallbackHID, "", nestedGroupHID)
×
930
                        end if
931
                    end if
932
                end if
933
            end for
934

935
            return ""
1✔
936
        end function
937

938
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
939
            newHID = ""
1✔
940

941
            ' Build ancestor chain (current group + all ancestors)
942
            ancestorGroups = m.findAncestorGroups(groupHID)
1✔
943
            ancestorGroups.unshift(groupHID)
1✔
944
            ancestorGroupsCount = ancestorGroups.Count()
1✔
945
            ancestorIndex = 0
1✔
946

947
            ' Get currently focused item for spatial navigation
948
            focused = m.focusItemStack.get(m.globalFocusHID)
1✔
949

950
            ' Bubble up through ancestor groups until we find a target or reach the top
951
            while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
1✔
952
                ' Get next ancestor group
953
                groupHID = ancestorGroups[ancestorIndex]
1✔
954
                group = m.groupStack.get(groupHID)
1✔
955

956
                ' Check group's direction configuration
957
                nodeId = group.getStaticNodeIdInDirection(direction)
1✔
958

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

1004
                ancestorIndex++
1✔
1005
            end while
1006

1007
            return newHID
1✔
1008
        end function
1009

1010
        ' * KEY EVENT HANDLER
1011
        function onKeyEventHandler(key as string, press as boolean) as object
1012
            ' Check long-press
1013
            if m.enableLongPressFeature = true
2✔
1014
                m.checkLongPressState(key, press)
1✔
1015
            end if
1016
            ' Prevent any navigation if it is disabled
1017
            #if debug
4✔
1018
            #end if
1019
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
2✔
1020
            ' Execute action according to key press
1021
            return m.executeNavigationAction(key, press)
1✔
1022
        end function
1023

1024
        function executeNavigationAction(key as string, press as boolean) as object
1025

1026
            if true = press
3✔
1027

1028
                if -1 < Rotor.Utils.findInArray([
2✔
1029
                        Rotor.Const.Direction.UP,
1030
                        Rotor.Const.Direction.RIGHT,
1031
                        Rotor.Const.Direction.DOWN,
1032
                        Rotor.Const.Direction.LEFT,
1033
                        Rotor.Const.Direction.BACK
1034
                    ], key)
1035

1036
                    newHID = ""
1✔
1037
                    direction = key
1✔
1038
                    m.lastNavigationDirection = direction
1✔
1039

1040
                    ' (1) Pick up current focused item
1041

1042
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
1043

1044
                    if focused = invalid
2✔
1045
                        #if debug
×
1046
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue detected. Last known focus id:\"${m.globalFocusHID}\". Please ensure valid focus.`
×
1047
                        #end if
1048
                        return m.parseOnKeyEventResult(key, false, false)
×
1049
                    end if
1050

1051

1052
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
1053
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
1054

1055
                    if ancestorGroupsCount = 0
2✔
1056
                        allFocusItems = m.focusItemStack.getAll()
1✔
1057
                        possibleFocusItems = allFocusItems.keys()
1✔
1058
                        parentGroupHID = ""
1✔
1059
                    else
3✔
1060
                        parentGroupHID = ancestorGroups[0]
1✔
1061
                        group = m.groupStack.get(parentGroupHID)
1✔
1062
                        possibleFocusItems = group.getGroupMembersHIDs()
1✔
1063
                    end if
1064

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

1068
                    if Rotor.Utils.isBoolean(nodeId) and nodeId = true
2✔
1069
                        ' It means that focus is handled, and no need further action by plugin.
1070
                        return m.parseOnKeyEventResult(key, true, false)
×
1071
                    end if
1072

1073
                    if nodeId <> ""
2✔
1074
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
1075
                    end if
1076

1077
                    if newHID = ""
3✔
1078
                        ' (3) Try spatial navigation in direction, among possible focusItems
1079
                        ' all = m.focusItemStack.getAll()
1080
                        ' allKeys = all.Keys()
1081
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
1✔
1082
                    end if
1083

1084
                    ' (4) Check if found group. FocusItem can not point out of group.
1085
                    if newHID = "" and ancestorGroupsCount > 0 ' (5/2) If this focused has parent group, lets try bubbling focus on ancestors (groups)
3✔
1086
                        newHID = m.bubblingFocus(parentGroupHID, direction)
1✔
1087
                        if Rotor.Utils.isBoolean(newHID)
2✔
1088
                            if newHID = true
3✔
1089
                                ' It means that focus is handled, and no need further action by plugin.
1090
                                return m.parseOnKeyEventResult(key, true, false)
1✔
1091
                            else
×
1092
                                newHID = ""
×
1093
                            end if
1094
                        end if
1095
                    end if
1096

1097
                    handled = m.setFocus(newHID)
1✔
1098
                    return m.parseOnKeyEventResult(key, handled, false)
1✔
1099

1100
                else if key = "OK"
2✔
1101

1102
                    return m.parseOnKeyEventResult(key, true, true)
1✔
1103

1104
                end if
1105
            end if
1106

1107
            return m.parseOnKeyEventResult(key, false, false)
1✔
1108

1109
        end function
1110

1111
        function parseOnKeyEventResult(key as string, handled as boolean, isSelected as boolean) as object
1112
            result = {
1✔
1113
                handled: handled,
1114
                key: key
1115
            }
1116
            if m.globalFocusHID <> "" and handled = true
2✔
1117
                focusItem = m.focusItemStack.get(m.globalFocusHID)
1✔
1118
                widget = m.widgetTree.get(focusItem.HID)
1✔
1119
                ' viewModelState = Rotor.Utils.deepCopy(widget.viewModelState)
1120
                result.widget = widget
1✔
1121
                if isSelected
2✔
1122
                    result.isSelected = isSelected
1✔
1123
                    focusItem.callOnSelectFnOnWidget()
1✔
1124
                end if
1125
            end if
1126
            return result
1✔
1127
        end function
1128

1129
        sub checkLongPressState(key as string, press as boolean)
1130
            m.longPressTimer.control = "stop"
1✔
1131
            if press = true
2✔
1132
                if m.isLongPress = false
3✔
1133
                    m.longPressKey = key
1✔
1134
                    m.longPressTimer.control = "start"
1✔
1135
                end if
1136
            else
3✔
1137
                wasLongPress = m.isLongPress = true
1✔
1138
                lastKey = m.longPressKey
1✔
1139
                m.isLongPress = false
1✔
1140
                m.longPressKey = ""
1✔
1141
                if wasLongPress
2✔
1142
                    m.delegateLongPressChanged(false, lastKey)
×
1143
                end if
1144
            end if
1145
        end sub
1146

1147
        function proceedLongPress() as object
1148
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(m.longPressKey, false, false)
1✔
1149
            return m.executeNavigationAction(m.longPressKey, true)
1✔
1150
        end function
1151

1152
        ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
1153
        ' @param focusedMetrics - The metrics object from focused.refreshBounding()
1154
        ' @param focusedHID - The HID of the focused item (to exclude from candidates)
1155
        function collectSegments(focusedMetrics as object, direction as string, focusItemsHIDlist as object, focusedHID as string) as object
1156
            refSegments = focusedMetrics.segments
1✔
1157
            refSegmentTop = refSegments[Rotor.Const.Segment.TOP]
1✔
1158
            refSegmentRight = refSegments[Rotor.Const.Segment.RIGHT]
1✔
1159
            refSegmentLeft = refSegments[Rotor.Const.Segment.LEFT]
1✔
1160
            refSegmentBottom = refSegments[Rotor.Const.Segment.BOTTOM]
1✔
1161

1162
            segments = {}
1✔
1163
            validator = m.spatialValidators[direction]
1✔
1164
            for each HID in focusItemsHIDlist
1✔
1165
                if HID <> focusedHID
3✔
1166
                    ' Try to get as FocusItem first, then as Group
1167
                    candidate = m.focusItemStack.get(HID)
1✔
1168
                    isGroup = false
1✔
1169
                    if candidate = invalid
2✔
1170
                        candidate = m.groupStack.get(HID)
×
1171
                        isGroup = true
×
1172
                    end if
1173
                    if candidate = invalid then continue for
2✔
1174

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

1178
                    candidateMetrics = candidate.refreshBounding()
1✔
1179
                    ' Pass appropriate reference segments based on direction
1180
                    if direction = "left" or direction = "right"
3✔
1181
                        result = validator(candidateMetrics.segments, refSegmentLeft, refSegmentRight)
1✔
1182
                    else ' up or down
3✔
1183
                        result = validator(candidateMetrics.segments, refSegmentTop, refSegmentBottom)
1✔
1184
                    end if
1185
                    if result.isValid
3✔
1186
                        segments[HID] = result.segment
1✔
1187
                    end if
1188
                end if
1189
            end for
1190

1191
            return segments
1✔
1192
        end function
1193

1194
        sub destroy()
1195
            ' Remove all groups
1196
            for each HID in m.groupStack.getAll()
1✔
1197
                m.groupStack.remove(HID)
1✔
1198
            end for
1199
            ' Remove all focus items
1200
            for each HID in m.focusItemStack.getAll()
1✔
1201
                m.focusItemStack.remove(HID)
1✔
1202
            end for
1203
            m.longPressTimer.unobserveFieldScoped("fire")
1✔
1204
            m.longPressTimer = invalid
1✔
1205
            m.widgetTree = invalid
1✔
1206
        end sub
1207

1208
    end class
1209

1210
    namespace FocusPluginHelper
1211

1212
        class BaseEntryStack extends Rotor.BaseStack
1213

1214
            function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
1215
                if ancestorHID <> "0"
2✔
1216
                    filteredStack = {}
1✔
1217
                    for each HID in m.stack
1✔
1218
                        if Rotor.Utils.isDescendantHID(HID, ancestorHID)
3✔
1219
                            filteredStack[HID] = m.get(HID)
1✔
1220
                        end if
1221
                    end for
1222
                else
3✔
1223
                    filteredStack = m.stack
1✔
1224
                end if
1225
                HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
1✔
1226
                return HID <> "" ? m.get(HID) : invalid
1✔
1227
            end function
1228

1229
            override sub remove(HID as string)
1230
                item = m.get(HID)
1✔
1231
                item.destroy()
1✔
1232
                super.remove(HID)
1✔
1233
            end sub
1234

1235
        end class
1236

1237
        class GroupStack extends BaseEntryStack
1238

1239
            function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1240
                foundHID = ""
×
1241
                for each HID in possibleGroups
×
1242
                    group = m.get(HID)
×
1243
                    if group.id = nodeId
×
1244
                        foundHID = group.HID
×
1245
                        exit for
1246
                    end if
1247
                end for
1248
                return foundHID
×
1249
            end function
1250

1251
        end class
1252

1253

1254
        class FocusItemStack extends BaseEntryStack
1255

1256
            function convertNodeIdToHID(nodeId as string, possibleFocusItems as object) as string
1257
                foundHID = ""
×
1258
                for each HID in possibleFocusItems
×
1259
                    focusItem = m.get(HID)
×
1260
                    if focusItem?.id = nodeId
×
1261
                        foundHID = focusItem.HID
×
1262
                        exit for
1263
                    end if
1264
                end for
1265
                return foundHID
×
1266
            end function
1267

1268
            function hasEnabled(HID as string) as boolean
1269
                if m.has(HID)
3✔
1270
                    focusItem = m.get(HID)
1✔
1271
                    return focusItem.isEnabled
1✔
1272
                else
3✔
1273
                    return false
1✔
1274
                end if
1275
            end function
1276

1277
        end class
1278

1279
        class BaseFocusConfig
1280

1281
            staticDirection as object
1282

1283
            sub new (config as object)
1284

1285
                m.HID = config.HID
1✔
1286
                m.id = config.id
1✔
1287

1288
                m.widget = config.widget
1✔
1289
                m.node = m.widget.node
1✔
1290
                m.isFocused = config.isFocused ?? false
1✔
1291

1292
                m.isEnabled = config.isEnabled ?? true
1✔
1293
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? false
1✔
1294
                m.staticDirection = {}
1✔
1295
                m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1✔
1296
                m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
1✔
1297
                m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
1✔
1298
                m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
1✔
1299
                m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
1✔
1300

1301
                m.onFocusChanged = config.onFocusChanged
1✔
1302
                m.longPressHandler = config.longPressHandler
1✔
1303
                m.onFocus = config.onFocus
1✔
1304
                m.onBlur = config.onBlur
1✔
1305

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

1308
                if m.widget.isViewModel = true and not m.widget.viewModelState.DoesExist("isFocused")
2✔
1309
                    m.widget.viewModelState.isFocused = false
×
1310
                end if
1311

1312
            end sub
1313

1314

1315
            HID as string
1316
            id as string
1317
            idByKeys as object
1318
            isEnabled as boolean
1319
            isFocused as boolean
1320
            enableSpatialNavigation as boolean
1321
            onFocusChanged as dynamic
1322
            onFocus as dynamic
1323
            onBlur as dynamic
1324
            longPressHandler as dynamic
1325
            node as object
1326
            widget as object
1327

1328
            protected metrics = {
1329
                segments: {}
1330
            }
1331

1332
            function refreshBounding() as object
1333
                b = m.node.sceneBoundingRect()
×
1334
                rotation = m.node.rotation
×
1335

1336
                if rotation = 0
×
1337
                    if b.y = 0 and b.x = 0
×
1338
                        t = m.node.translation
×
1339
                        b.x += t[0]
×
1340
                        b.y += t[1]
×
1341
                    end if
1342

1343
                    m.metrics.append(b)
×
1344
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
×
1345
                        x1: b.x, y1: b.y,
1346
                        x2: b.x, y2: b.y + b.height
1347
                    }
1348
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
×
1349
                        x1: b.x, y1: b.y,
1350
                        x2: b.x + b.width, y2: b.y
1351
                    }
1352
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
×
1353
                        x1: b.x + b.width, y1: b.y,
1354
                        x2: b.x + b.width, y2: b.y + b.height
1355
                    }
1356
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
×
1357
                        x1: b.x, y1: b.y + b.height,
1358
                        x2: b.x + b.width, y2: b.y + b.height
1359
                    }
1360
                    m.metrics.middlePoint = {
×
1361
                        x: b.x + b.width / 2,
1362
                        y: b.y + b.height / 2
1363
                    }
1364
                end if
1365

1366
                return m.metrics
×
1367
            end function
1368

1369
            function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1370
                direction = m.staticDirection[direction]
1✔
1371
                if Rotor.Utils.isFunction(direction)
2✔
1372
                    return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
×
1373
                else
3✔
1374
                    return direction ?? ""
1✔
1375
                end if
1376
            end function
1377

1378
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1379
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1380
                if true = isFocused
3✔
1381
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1382
                else
3✔
1383
                    Rotor.Utils.callbackScoped(m.onBlur, m.widget)
1✔
1384
                end if
1385
            end sub
1386

1387
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1388
                if Rotor.Utils.isFunction(m.longPressHandler)
2✔
1389
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1390
                else
3✔
1391
                    return false
1✔
1392
                end if
1393
            end function
1394

1395
            sub destroy()
1396
                m.widget = invalid
1✔
1397
                m.node = invalid
1✔
1398
                m.onFocusChanged = invalid
1✔
1399
                m.onFocus = invalid
1✔
1400
                m.onBlur = invalid
1✔
1401
                m.longPressHandler = invalid
1✔
1402
            end sub
1403

1404
        end class
1405

1406
        class GroupClass extends BaseFocusConfig
1407
            ' Note: Spatial navigation is supported within group, there is no spatial navigation between groups
1408
            ' If you want to focus out to another group, you need to config a direction prop.
1409
            ' You can set a groupId or any focusItem widgetId.
1410
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1411
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1412

1413
            sub new (config as object)
1414
                super(config)
1✔
1415
                m.defaultFocusId = config.defaultFocusId ?? ""
1✔
1416
                m.lastFocusedHID = config.lastFocusedHID ?? ""
1✔
1417
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
1✔
1418
                m.enableLastFocusId = config.enableLastFocusId ?? true
1419
                m.enableDeepLastFocusId = config.enableDeepLastFocusId ?? false
1✔
1420
            end sub
1421

1422
            defaultFocusId as string
1423
            lastFocusedHID as string
1424
            enableSpatialEnter as dynamic ' boolean | { up?: boolean, down?: boolean, left?: boolean, right?: boolean }
1425
            enableLastFocusId as boolean
1426
            enableDeepLastFocusId as boolean
1427

1428
            '
1429
            ' isSpatialEnterEnabledForDirection - Checks if spatial enter is enabled for a specific direction
1430
            '
1431
            ' @param {string} direction - The direction to check (up, down, left, right)
1432
            ' @returns {boolean} True if spatial enter is enabled for the direction
1433
            '
1434
            function isSpatialEnterEnabledForDirection(direction as string) as boolean
1435
                if Rotor.Utils.isBoolean(m.enableSpatialEnter)
3✔
1436
                    return m.enableSpatialEnter
1✔
1437
                else if Rotor.Utils.isAssociativeArray(m.enableSpatialEnter)
3✔
1438
                    return m.enableSpatialEnter[direction] = true
1✔
1439
                end if
1440
                return false
×
1441
            end function
1442
            focusItemsRef as object
1443
            groupsRef as object
1444
            isFocusItem = false
1445
            isGroup = true
1446

1447
            sub setLastFocusedHID(lastFocusedHID as string)
1448
                m.lastFocusedHID = lastFocusedHID
1✔
1449
            end sub
1450

1451
            function getGroupMembersHIDs()
1452
                ' Collect all focusItems that are descendants of this group
1453
                ' Exclude items that belong to nested sub-groups
1454
                ' Also include direct child groups with enableSpatialNavigation: true
1455
                focusItems = m.focusItemsRef.getAll()
1✔
1456
                groups = m.groupsRef.getAll()
1✔
1457
                HIDlen = Len(m.HID)
1✔
1458
                collection = []
1✔
1459
                groupsKeys = groups.keys()
1✔
1460
                groupsCount = groups.Count()
1✔
1461

1462
                ' Collect focusItems (existing logic)
1463
                for each focusItemHID in focusItems
1✔
1464
                    ' Check if focusItem is a descendant of this group
1465
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
1✔
1466
                    if isDescendant
3✔
1467
                        ' Check if focusItem belongs to a nested sub-group
1468
                        shouldExclude = false
1✔
1469
                        otherGroupIndex = 0
1✔
1470
                        while shouldExclude = false and otherGroupIndex < groupsCount
1✔
1471
                            otherGroupHID = groupsKeys[otherGroupIndex]
1✔
1472
                            otherGroupHIDlen = Len(otherGroupHID)
1✔
1473
                            ' Exclude if belongs to deeper nested group
1474
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
1✔
1475
                            otherGroupIndex++
1✔
1476
                        end while
1477

1478
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1479
                    end if
1480
                end for
1481

1482
                ' Collect direct child groups with enableSpatialNavigation: true
1483
                for i = 0 to groupsCount - 1
1✔
1484
                    childGroupHID = groupsKeys[i]
1✔
1485
                    childGroupHIDlen = Len(childGroupHID)
1✔
1486

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

1491
                        ' Only include if enableSpatialNavigation is true
1492
                        if childGroup.enableSpatialNavigation = true
2✔
1493
                            ' Check if it's a DIRECT child (no intermediate groups)
1494
                            isDirectChild = true
×
1495
                            for j = 0 to groupsCount - 1
×
1496
                                intermediateHID = groupsKeys[j]
×
1497
                                intermediateLen = Len(intermediateHID)
×
1498
                                ' Check if there's a group between this group and the child
1499
                                if intermediateLen > HIDlen and intermediateLen < childGroupHIDlen
×
1500
                                    if Left(childGroupHID, intermediateLen) = intermediateHID
×
1501
                                        isDirectChild = false
×
1502
                                        exit for
1503
                                    end if
1504
                                end if
1505
                            end for
1506

1507
                            if isDirectChild then collection.push(childGroupHID)
×
1508
                        end if
1509
                    end if
1510
                end for
1511

1512
                return collection
1✔
1513
            end function
1514

1515
            '
1516
            ' getFallbackNodeId - Returns the nodeId to use for fallback (defaultFocusId or lastFocusedHID)
1517
            '
1518
            ' @returns {string} The nodeId to use for fallback, or empty string if none
1519
            '
1520
            function getFallbackNodeId() as string
1521
                if m.lastFocusedHID <> ""
2✔
1522
                    ' Note: lastFocusedHID is already a HID, not a nodeId, so we need to get the nodeId
1523
                    lastFocusedItem = m.focusItemsRef.get(m.lastFocusedHID)
×
1524
                    if lastFocusedItem <> invalid
×
1525
                        return lastFocusedItem.id
×
1526
                    end if
1527
                end if
1528

1529
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1530
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1531
                else
3✔
1532
                    return m.defaultFocusId
1✔
1533
                end if
1534
            end function
1535

1536
            function getFallbackIdentifier(globalFocusHID = "" as string, direction = "" as string) as string
1537
                HID = ""
1✔
1538
                ' enableSpatialEnter is handled by capturingFocus_recursively using spatialNavigation
1539
                ' Here we only handle lastFocusedHID and defaultFocusId fallbacks
1540

1541
                ' Use lastFocusedHID if available AND still exists (check both focusItems and groups)
1542
                if not m.isSpatialEnterEnabledForDirection(direction) and m.lastFocusedHID <> ""
2✔
1543
                    if m.focusItemsRef.has(m.lastFocusedHID) or m.groupsRef.has(m.lastFocusedHID)
3✔
1544
                        return m.lastFocusedHID
1✔
1545
                    end if
1546
                end if
1547

1548
                ' Default: use defaultFocusId expression
1549
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1550
                    defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
1✔
1551
                else
3✔
1552
                    defaultFocusId = m.defaultFocusId
1✔
1553
                end if
1554

1555
                focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1556

1557
                if defaultFocusId <> ""
2✔
1558
                    if focusItemsHIDlist.Count() > 0
3✔
1559
                        ' Try find valid HID in focusItems by node id
1560
                        focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1✔
1561
                        if focusItemHID <> ""
3✔
1562
                            return focusItemHID
1✔
1563
                        end if
1564
                    end if
1565
                    ' If not found as focusItem, return defaultFocusId string
1566
                    ' so capturingFocus_recursively can try to resolve it as a group
1567
                    return defaultFocusId
1✔
1568
                end if
1569

1570
                ' Last resort: pick the first available member of the group
1571
                if focusItemsHIDlist.Count() > 0
3✔
1572
                    return focusItemsHIDlist[0]
1✔
1573
                end if
1574

UNCOV
1575
                return HID
×
1576
            end function
1577

1578
            function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1579
                for each itemHID in focusItemsHIDlist
1✔
1580
                    focusItem = m.focusItemsRef.get(itemHID)
1✔
1581
                    if focusItem <> invalid and focusItem.id = nodeId
3✔
1582
                        return focusItem.HID
1✔
1583
                    end if
1584
                end for
1585
                return ""
×
1586
            end function
1587

1588
            sub applyFocus(isFocused as boolean)
1589
                if m.isFocused = isFocused then return
2✔
1590

1591
                m.isFocused = isFocused
1✔
1592
                m.node.setField("isFocused", isFocused)
1✔
1593
                if m.widget.isViewModel = true then m.widget.viewModelState.isFocused = isFocused
2✔
1594
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1595
            end sub
1596

1597
            override sub destroy()
1598
                super.destroy()
1✔
1599
                m.focusItemsRef = invalid
1✔
1600
                m.groupsRef = invalid
1✔
1601
            end sub
1602

1603

1604

1605
        end class
1606

1607
        class FocusItemClass extends BaseFocusConfig
1608

1609
            sub new (config as object)
1610
                super(config)
1✔
1611

1612
                m.onSelect = config.onSelect ?? ""
1✔
1613
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1614
            end sub
1615

1616
            ' You can set a groupId or any focusItem widgetId.
1617
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1618
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1619

1620
            ' key as string
1621
            isFocusItem = true
1622
            isGroup = false
1623
            enableNativeFocus as boolean
1624
            onSelect as dynamic
1625

1626
            private bounding as object
1627

1628

1629
            override function refreshBounding() as object
1630
                b = m.node.sceneBoundingRect()
1✔
1631
                rotation = m.node.rotation
1✔
1632

1633
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1634
                ' That is why we can use translation without knowing the value of inheritParentTransform
1635
                ' If bounding x or y are not zero, then bounding will include the node's translation
1636
                if rotation = 0
3✔
1637
                    if b.y = 0 and b.x = 0
2✔
1638
                        t = m.node.translation
1✔
1639
                        b.x += t[0]
1✔
1640
                        b.y += t[1]
1✔
1641
                    end if
1642

1643
                    m.metrics.append(b)
1✔
1644
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
1✔
1645
                        x1: b.x, y1: b.y,
1646
                        x2: b.x, y2: b.y + b.height
1647
                    }
1648
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
1✔
1649
                        x1: b.x, y1: b.y,
1650
                        x2: b.x + b.width, y2: b.y
1651
                    }
1652
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
1✔
1653
                        x1: b.x + b.width, y1: b.y,
1654
                        x2: b.x + b.width, y2: b.y + b.height
1655
                    }
1656
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
1✔
1657
                        x1: b.x, y1: b.y + b.height,
1658
                        x2: b.x + b.width, y2: b.y + b.height
1659
                    }
1660
                    m.metrics.middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
1✔
1661
                else
×
1662
                    scaleRotateCenter = m.node.scaleRotateCenter
×
1663
                    dims = m.node.localBoundingRect() ' We need this to get proper (rotated value of rotated x and y)
×
1664
                    if b.y = 0 and b.x = 0
×
1665
                        t = m.node.translation
×
1666
                        b.x += t[0]
×
1667
                        b.y += t[1]
×
1668
                    end if
1669
                    b.width = dims.width
×
1670
                    b.height = dims.height
×
1671
                    m.metrics.append(b)
×
1672

1673
                    ' Calculate rotated segments
1674
                    segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
×
1675
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1676
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
×
1677

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

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

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

1690
                    ' Calculate rotated middle point
1691
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1692
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1693
                    m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
×
1694

1695
                end if
1696

1697
                return m.metrics
1✔
1698
            end function
1699

1700
            override sub destroy()
1701
                m.onSelect = invalid
1✔
1702
                m.metrics.segments.Clear()
1✔
1703
                super.destroy()
1✔
1704
            end sub
1705

1706
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1707
                if m.isFocused = isFocused then return
2✔
1708

1709
                m.isFocused = isFocused
1✔
1710
                m.node.setField("isFocused", isFocused)
1✔
1711
                if m.widget.isViewModel = true then m.widget.viewModelState.isFocused = isFocused
2✔
1712

1713
                if enableNativeFocus or m.enableNativeFocus
2✔
1714
                    m.node.setFocus(isFocused)
1✔
1715
                end if
1716

1717
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1718

1719
            end sub
1720

1721
            sub callOnSelectFnOnWidget()
1722
                Rotor.Utils.callbackScoped(m.onSelect, m.widget)
1✔
1723
            end sub
1724

1725
        end class
1726

1727
        class ClosestSegmentToPointCalculatorClass
1728

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

1732
                A = x - x1
1✔
1733
                B = y - y1
1✔
1734
                C = x2 - x1
1✔
1735
                D = y2 - y1
1✔
1736

1737
                dot = A * C + B * D
1✔
1738
                len_sq = C * C + D * D
1✔
1739
                param = -1
1✔
1740
                if len_sq <> 0
3✔
1741
                    param = dot / len_sq
1✔
1742
                end if
1743

1744
                xx = 0
1✔
1745
                yy = 0
1✔
1746

1747
                if param < 0
2✔
1748
                    xx = x1
1✔
1749
                    yy = y1
1✔
1750
                else if param > 1
2✔
1751
                    xx = x2
1✔
1752
                    yy = y2
1✔
1753
                else
3✔
1754
                    xx = x1 + param * C
1✔
1755
                    yy = y1 + param * D
1✔
1756
                end if
1757

1758
                dx = x - xx
1✔
1759
                dy = y - yy
1✔
1760
                return dx * dx + dy * dy
1✔
1761
            end function
1762

1763
            function distToSegment(p as object, s1 as object, s2 as object)
1764
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1765
            end function
1766

1767
        end class
1768

1769
    end namespace
1770

1771
    namespace FocusPluginHelper
1772

1773
        sub longPressObserverCallback(msg)
1774
            extraInfo = msg.GetInfo()
1✔
1775

1776
            pluginKey = extraInfo["pluginKey"]
1✔
1777

1778
            globalScope = GetGlobalAA()
1✔
1779
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
1✔
1780
            plugin = frameworkInstance.plugins[pluginKey]
1✔
1781
            plugin.isLongPress = true
1✔
1782
            ' plugin.longPressStartHID = plugin.globalFocusHID
1783
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
1✔
1784

1785
        end sub
1786

1787
    end namespace
1788

1789
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