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

mobalazs / rotor-framework / 18919729013

29 Oct 2025 07:21PM UTC coverage: 85.379% (-0.1%) from 85.479%
18919729013

push

github

mobalazs
fix: update debug setting in bsconfig to enable debugging

1781 of 2086 relevant lines covered (85.38%)

1.16 hits per line

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

72.51
/src/source/plugins/FocusPlugin.bs
1
' TODO: Future improvement: integrate deviceinfo TimeSinceLastKeypress() -> idle time
2
' TODO: Future improvement: key combo detector
3

4
namespace Rotor
5

6
    ' =====================================================================
7
    ' FocusPlugin - Handles focus logic, focus groups, and spatial navigation
8
    '
9
    ' A Brighterscript class for handling focus logic, focus groups,
10
    ' and spatial navigation within the Rotor framework.
11
    '
12
    ' ═══════════════════════════════════════════════════════════════
13
    ' CONCEPTUAL OVERVIEW: BUBBLING vs CAPTURING
14
    ' ═══════════════════════════════════════════════════════════════
15
    '
16
    ' This plugin implements two complementary focus resolution strategies:
17
    '
18
    ' 1. BUBBLING FOCUS (bubblingFocus) - "Upward Search"
19
    '    ┌─────────────────────────────────────────────────────────┐
20
    '    │ WHEN: User interaction (key press) cannot find target   │
21
    '    │ DIRECTION: Child → Parent → Grandparent (upward)        │
22
    '    │ PURPOSE: "I can't navigate further, ask my parents"     │
23
    '    └─────────────────────────────────────────────────────────┘
24
    '
25
    '    Example: User presses UP from a focused item, but there's
26
    '    no item above. The plugin "bubbles up" through ancestor
27
    '    groups to find an alternative navigation path defined at
28
    '    a higher level.
29
    '
30
    ' 2. CAPTURING FOCUS (capturingFocus_recursively) - "Downward Rescue"
31
    '    ┌─────────────────────────────────────────────────────────┐
32
    '    │ WHEN: Need to resolve abstract target to concrete item  │
33
    '    │ DIRECTION: Group → Nested Group → FocusItem (downward)  │
34
    '    │ PURPOSE: "Found a group/ID, find the actual item"       │
35
    '    └─────────────────────────────────────────────────────────┘
36
    '
37
    '    This is a "rescue operation" that converts:
38
    '    - Group reference → concrete FocusItem
39
    '    - ID string → actual widget with focus capability
40
    '
41
    '    Example: Bubbling found "menuGroup", but we need a specific
42
    '    focusable item. Capturing recursively descends through the
43
    '    group's defaultFocusId chain until it finds a real FocusItem.
44
    '
45
    '
46
    ' DEEP SEARCH ENHANCEMENT:
47
    '    The capturing process now searches deeply in hierarchies.
48
    '    If defaultFocusId doesn't match immediate children, it will:
49
    '    - Search all descendant FocusItems (any depth)
50
    '    - Search all nested Groups (any depth)
51
    '    - Apply fallback logic if a matching Group is found
52
    '
53
    '    This means defaultFocusId: "deepItem" will find "deepItem"
54
    '    even if it's 3+ levels deep in the hierarchy!
55
    '
56
    ' TOGETHER THEY WORK AS:
57
    '    User Action → Bubbling (↑ find alternative) → Capturing (↓ resolve target)
58
    '
59
    ' ═══════════════════════════════════════════════════════════════
60
    ' COMPLETE RULES REFERENCE
61
    ' ═══════════════════════════════════════════════════════════════
62
    '
63
    ' RULE #1: Widget Types
64
    '   - focus: { group: {...} } → Group (container)
65
    '   - focus: {...} (no group key) → FocusItem (focusable element)
66
    '   - No focus config → Not part of focus system
67
    '
68
    ' RULE #2: FocusItem Direction Values
69
    '   - String (Node ID): Static navigation to that element
70
    '   - Function: Dynamic, evaluated at runtime
71
    '   - false: Blocks the direction (nothing happens)
72
    '   - true/undefined/empty string: Spatial navigation attempts
73
    '
74
    ' RULE #3: Navigation Priority (Decreasing Order)
75
    '   1. FocusItem static direction (left: "button2")
76
    '   2. Spatial navigation (within group only)
77
    '   3. BubblingFocus (ask parent groups)
78
    '
79
    ' RULE #4: Spatial Navigation Scope
80
    '   - ONLY works within a single group
81
    '   - Cannot cross into sibling or parent groups
82
    '   - Searches only possibleFocusItems from group.getGroupMembersHIDs()
83
    '
84
    ' RULE #5: Group Direction Activation
85
    '   Group direction triggers ONLY when:
86
    '   - FocusItem has NO static direction
87
    '   - Spatial navigation found NOTHING
88
    '   - BubblingFocus reaches this group
89
    '
90
    ' RULE #6: Group Direction Values
91
    '   - String (Node ID): Navigate to that group/item (may EXIT group)
92
    '   - true: BLOCKS (stays on current element)
93
    '   - false/undefined: Continue bubbling to next ancestor
94
    '
95
    ' RULE #7: Group Direction Does NOT Block Spatial Navigation
96
    '   Setting group.right = true does NOT prevent spatial navigation
97
    '   INSIDE the group. It only blocks EXITING the group when spatial
98
    '   navigation finds nothing.
99
    '
100
    ' RULE #8: Exiting a Group - 3 Methods
101
    '   Method 1: FocusItem explicit direction
102
    '     focusItem.right = "otherGroupItem" → EXITS immediately
103
    '   Method 2: Group direction (via BubblingFocus)
104
    '     group.right = "otherGroup" → EXITS when spatial nav fails
105
    '   Method 3: Ancestor group direction
106
    '     parentGroup.right = "otherGroup" → EXITS when child groups pass
107
    '
108
    ' RULE #9: Blocking Group Exit
109
    '   To prevent exit: group.left = true, group.right = true
110
    '   Exception: FocusItem explicit directions still work!
111
    '
112
    ' RULE #10: BubblingFocus Flow
113
    '   FocusItem (no direction) → Spatial nav (nothing) → Group.direction?
114
    '     - "nodeId" → CapturingFocus(nodeId) [EXIT]
115
    '     - true → STOP (stay on current)
116
    '     - false/undefined → Continue to parent group
117
    '     - No more ancestors → Stay on current
118
    '
119
    ' RULE #11: CapturingFocus Priority
120
    '   1. group.lastFocusedHID (if exists) [AUTO-SAVED]
121
    '   2. group.defaultFocusId [CONFIGURED]
122
    '   3. Deep search (if defaultFocusId not found immediately)
123
    '
124
    ' RULE #12: DefaultFocusId Targets
125
    '   - FocusItem node ID → Focus goes directly to it
126
    '   - Group node ID → Capturing continues on that group
127
    '   - Non-existent ID → Deep search attempts
128
    '
129
    ' RULE #13: Deep Search Activation
130
    '   Triggers when:
131
    '   - CapturingFocus doesn't find defaultFocusId in immediate children
132
    '   - defaultFocusId is not empty
133
    '   Searches:
134
    '   1. All descendant FocusItems (any depth)
135
    '   2. All nested Groups (any depth, applies their fallback)
136
    '
137
    ' RULE #14: Spatial Enter
138
    '   When enableSpatialEnter = true on a group:
139
    '   - Entering the group uses spatial navigation from the direction
140
    '   - Finds geometrically closest item instead of defaultFocusId
141
    '   - Falls back to defaultFocusId if spatial finds nothing
142
    '
143
    ' RULE #15: Navigation Decision Tree Summary
144
    '   User presses direction key:
145
    '     1. FocusItem.direction exists? → Use it (may EXIT group)
146
    '     2. Spatial nav finds item? → Navigate (STAYS in group)
147
    '     3. BubblingFocus: Group.direction?
148
    '        - "nodeId" → EXIT to that target
149
    '        - true → BLOCK (stay)
150
    '        - undefined → Continue to ancestor
151
    '     4. No more ancestors? → STAY on current item
152
    '
153
    ' COMMON PATTERNS:
154
    '   Sidebar + Content:
155
    '     sidebar: { group: { right: true } }
156
    '     menuItem1: { right: "contentFirst" } [explicit exit]
157
    '
158
    '   Modal Dialog (locked):
159
    '     modal: { group: { left: true, right: true, up: true, down: true } }
160
    '
161
    '   Nested Navigation:
162
    '     innerGroup: { group: { down: undefined } } [no direction]
163
    '     outerGroup: { group: { down: "bottomBar" } } [catches bubbling]
164
    '
165
    ' =====================================================================
166
    class FocusPlugin extends Rotor.BasePlugin
167

168
        ' ---------------------------------------------------------------------
169
        ' new - Constructor for the FocusPlugin
170
        '
171
        ' @param {string} key - The key to identify this plugin instance (default: "focus")
172
        '
173
        sub new(key = "focus" as string)
174
            super(key)
1✔
175
        end sub
176

177
        ' Framework lifecycle hooks
178
        hooks = {
179
            ' ---------------------------------------------------------------------
180
            ' beforeMount - Hook executed before a widget is mounted
181
            '
182
            ' Sets initial focus config.
183
            '
184
            ' @param {object} scope - The plugin scope (this instance)
185
            ' @param {object} widget - The widget being mounted
186
            '
187
            beforeMount: sub(scope as object, widget as object)
188
                scope.setFocusConfig(widget, widget[scope.key])
1✔
189
            end sub,
190

191
            ' ---------------------------------------------------------------------
192
            ' beforeUpdate - Hook executed before a widget is updated
193
            '
194
            ' Removes old config, applies new.
195
            '
196
            ' @param {object} scope - The plugin scope (this instance)
197
            ' @param {object} widget - The widget being updated
198
            ' @param {dynamic} newValue - The new plugin configuration value
199
            ' @param {object} oldValue - The previous plugin configuration value (default: {})
200
            '
201
            beforeUpdate: sub(scope as object, widget as object, newValue, oldValue = {})
202
                ' Remove previous config before applying the update
203
                scope.removeFocusConfig(widget.HID)
×
204

205
                ' Merge new config into existing widget config
206
                Rotor.Utils.deepExtendAA(widget[scope.key], newValue)
×
207
                scope.setFocusConfig(widget, widget[scope.key])
×
208
            end sub,
209

210
            ' ---------------------------------------------------------------------
211
            ' beforeDestroy - Hook executed before a widget is destroyed
212
            '
213
            ' Removes focus config.
214
            '
215
            ' @param {object} scope - The plugin scope (this instance)
216
            ' @param {object} widget - The widget being destroyed
217
            '
218
            beforeDestroy: sub(scope as object, widget as object)
219
                scope.removeFocusConfig(widget.HID)
1✔
220
            end sub
221
        }
222

223
        ' Widget methods - Injected into widgets managed by this plugin
224
        widgetMethods = {
225

226
            ' ---------------------------------------------------------------------
227
            ' enableFocusNavigation - Enables or disables focus navigation globally for this plugin
228
            '
229
            ' @param {boolean} enableFocusNavigation - True to enable, false to disable (default: true)
230
            '
231
            enableFocusNavigation: sub(enableFocusNavigation = true as boolean)
232
                globalScope = GetGlobalAA()
×
233
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
×
234
                globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].enableFocusNavigation = enableFocusNavigation
×
235
            end sub,
236

237
            ' ---------------------------------------------------------------------
238
            ' isFocusNavigationEnabled - Checks if focus navigation is currently enabled globally
239
            '
240
            ' @returns {boolean} True if enabled, false otherwise
241
            '
242
            isFocusNavigationEnabled: function() as boolean
243
                globalScope = GetGlobalAA()
1✔
244
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
1✔
245
                return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].enableFocusNavigation
1✔
246
            end function,
247

248
            ' ---------------------------------------------------------------------
249
            ' setFocus - Sets focus to this widget or another specified widget
250
            '
251
            ' @param {dynamic} isFocused - Boolean to focus/blur current widget, or string ID/HID of widget to focus
252
            ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node
253
            ' @returns {boolean} True if focus state was changed successfully, false otherwise
254
            '
255
            setFocus: function(command = true as dynamic, enableNativeFocus = false as boolean) as boolean
256
                globalScope = GetGlobalAA()
1✔
257
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
1✔
258
                HID = m.HID ' Widget's unique Hierarchical ID (bound viewModelState).
1✔
259

260
                if Rotor.Utils.isString(command)
2✔
261
                    ' If string, focus widget by ID/HID.
262
                    otherId = command
1✔
263
                    return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].setFocus(otherId, true, enableNativeFocus)
1✔
264
                else ' Boolean value provided
265
                    ' If boolean, focus/blur current widget.
3✔
266
                    return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].setFocus(HID, command, enableNativeFocus)
1✔
267
                end if
268
            end function,
269

270
            ' ---------------------------------------------------------------------
271
            ' getFocusedWidget - Retrieves the currently focused widget managed by this plugin
272
            '
273
            ' @returns {object} The widget instance that currently holds focus, or invalid
274
            '
275
            getFocusedWidget: function() as object ' Params isFocused, enableNativeFocus seem unused here
276
                globalScope = GetGlobalAA()
1✔
277
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
1✔
278
                return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].getFocusedWidget()
1✔
279
            end function,
280

281
            ' ---------------------------------------------------------------------
282
            ' proceedLongPress - Manually triggers the navigation action associated with the current long-press key
283
            '
284
            ' @returns {object} The result of the executed navigation action (see parseOnKeyEventResult)
285
            '
286
            proceedLongPress: function() as object ' Params isFocused, enableNativeFocus seem unused here
287
                globalScope = GetGlobalAA()
×
288
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
×
289
                return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].proceedLongPress()
×
290
            end function,
291

292
            ' ---------------------------------------------------------------------
293
            ' isLongPressActive - Checks if a long press action is currently active
294
            '
295
            ' @returns {boolean} True if a long press is active, false otherwise
296
            '
297
            isLongPressActive: function() as boolean ' Params isFocused, enableNativeFocus seem unused here
298
                globalScope = GetGlobalAA()
×
299
                pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
×
300
                return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].isLongPress
×
301
            end function,
302

303
            ' ---------------------------------------------------------------------
304
            ' triggerKeyPress - Simulate key press
305
            '
306
            ' @param {string} key - Pressed key
307
            ' @returns {object} The widget instance that currently holds focus, or invalid
308
            '
309
            triggerKeyPress: function(key) as object
310
                globalScope = GetGlobalAA()
1✔
311
                pluginKey = m.pluginKey
1✔
312
                return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].onKeyEventHandler(key, true)
1✔
313
            end function
314

315
        }
316

317
        ' Configuration
318
        longPressDuration = 0.4
319
        enableLongPressFeature = true
320
        enableFocusNavigation = true
321

322
        ' State tracking
323
        globalFocusHID = ""
324
        globalFocusId = ""
325
        isLongPress = false
326
        longPressKey = ""
327

328
        ' References
329
        widgetTree as object
330
        frameworkInstance as Rotor.Framework
331

332
        ' Helper objects
333
        focusItemStack = new Rotor.FocusPluginHelper.FocusItemStack()
334
        groupStack = new Rotor.FocusPluginHelper.GroupStack()
335
        distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass()
336
        longPressTimer = CreateObject("roSGNode", "Timer")
337

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

354
        '
355
        ' storeGlobalFocusHID - Stores the globally focused widget's HID and ID
356
        '
357
        ' @param {string} HID - The Hierarchical ID of the focused widget
358
        ' @param {string} id - The regular ID of the focused widget
359
        '
360
        sub storeGlobalFocusHID(HID as string, id as string)
361
            ' Store focus reference within the plugin
362
            m.globalFocusHID = HID
1✔
363
            m.globalFocusId = id
1✔
364
        end sub
365

366
        '
367
        ' getFocusedWidget - Gets the widget instance that currently holds global focus
368
        '
369
        ' @returns {object} The focused widget object, or invalid if none
370
        '
371
        function getFocusedWidget() as object
372
            return m.getFocusedItem()?.widget
1✔
373
        end function
374

375
        '
376
        ' getFocusedItem - Gets the FocusItem instance corresponding to the globally focused widget
377
        '
378
        ' @returns {object} The FocusItem instance, or invalid if none
379
        '
380
        function getFocusedItem() as object
381
            return m.focusItemStack.get(m.globalFocusHID)
1✔
382
        end function
383

384
        '
385
        ' setFocusConfig - Configures focus properties (FocusItem and/or Group) for a widget
386
        '
387
        ' @param {object} widget - The widget to configure
388
        ' @param {object} pluginConfig - The focus configuration object from the widget's spec
389
        '
390
        sub setFocusConfig(widget as object, pluginConfig as object)
391

392
            if pluginConfig = invalid then return ' No config provided
2✔
393
            HID = widget.HID
1✔
394
            id = widget.id
1✔
395

396
            ' Make a copy to avoid modifying the original config
397
            config = Rotor.Utils.deepCopy(pluginConfig)
1✔
398
            isGroup = config.doesExist(Rotor.Const.GROUP_CONFIG_KEY)
1✔
399
            ' An item is focusItem if it's explicitly a group with other props, or not a group but has props.
400
            ' isFocusItem = (isGroup = true and config.Count() > 1) or (isGroup = false and config.Count() > 0)
401

402
            ' Ensure essential identifiers are in the config
403
            config.id = id
1✔
404
            config.HID = widget.HID
1✔
405

406
            ' Handle group configuration if present
407
            if isGroup
2✔
408
                m.setupGroup(HID, config, widget)
1✔
409
            else
410
                ' Handle focus item configuration if applicable
411
                ' if isFocusItem
3✔
412
                m.setupFocusItem(HID, config, widget)
1✔
413
            end if
414
        end sub
415

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

436
        '
437
        ' setupFocusItem - Creates and registers a new Focus Item based on configuration
438
        '
439
        ' @param {string} HID - The Hierarchical ID of the focusItem widget
440
        ' @param {object} config - The full focus configuration for the widget
441
        ' @param {object} widget - The widget instance itself
442
        '
443
        sub setupFocusItem(HID as string, config as object, widget as object)
444
            config.widget = widget ' Ensure widget reference is in the config
1✔
445

446
            ' Create and register the FocusItem instance
447
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
1✔
448
            m.focusItemStack.set(HID, newFocusItem)
1✔
449
        end sub
450

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

469
            ' Note:
470
            ' - Parent group is at index 0
471
            ' - If HID is a focusItem, its direct parent group is included
472
            ' - If HID is a group, the group itself is NOT included
473
            return ancestorGroups
1✔
474
        end function
475

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

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

504
            ' Resolve reference (HID or ID) to a focusItem item.
505
            focusItem = invalid ' Initialize target focus item
1✔
506

507
            ' Exit if reference is empty or invalid.
508
            if ref = invalid or ref = "" then return false
2✔
509

510
            if m.focusItemStack.has(ref)
2✔
511
                ' Case 1: ref is a valid focusItem HID.
512
                focusItem = m.focusItemStack.get(ref)
1✔
513
            else
514
                ' Case 2: ref might be a focusItem node ID.
3✔
515
                focusItem = m.focusItemStack.getByNodeId(ref)
1✔
516

517
                if focusItem = invalid
3✔
518
                    ' Case 3: ref might be a group HID or group node ID.
519
                    ' Try finding group by HID first, then by Node ID.
520
                    group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
1✔
521
                    if group <> invalid
3✔
522
                        ' If group found, find its default/entry focus item recursively.
523
                        HID = m.capturingFocus_recursively(group.HID)
1✔
524
                        focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
1✔
525

526
                        ' else: ref is not a known FocusItem HID or Group identifier
527
                    end if
528
                end if
529
            end if
530

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

551
            ' Found a valid focusItem to target
552
            HID = focusItem.HID
1✔
553

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

558
            ' Cannot focus an invisible item.
559
            if focusItem.node.visible = false and isFocused = true then return false
2✔
560

561
            ' Determine if native focus should be enabled (request or item default)
562
            enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
1✔
563

564
            ' Prevent focusing a disabled item.
565
            preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
1✔
566
            if preventFocusOnDisabled
2✔
567
                return false ' Indicate focus change failed
×
568
            end if
569

570
            ' Handle blurring the previously focused item
571
            lastFocusChainingGroups = []
1✔
572
            if m.globalFocusHID <> "" ' If something was focused before
2✔
573
                lastFocused = m.focusItemStack.get(m.globalFocusHID)
1✔
574
                if lastFocused <> invalid ' Check if the last focused widget hasn't been destroyed
3✔
575
                    ' Blur the previously focused item
576
                    lastFocused.applyFocus(false, enableNativeFocus)
1✔
577

578
                    ' Record the last focused item within its parent group for potential future use (e.g., returning focus)
579
                    lastFocusChainingGroups = m.findAncestorGroups(m.globalFocusHID)
1✔
580
                    if lastFocusChainingGroups.Count() > 0
3✔
581
                        parentGroupHID = lastFocusChainingGroups[0]
1✔
582
                        if parentGroupHID <> invalid and parentGroupHID <> ""
3✔
583
                            group = m.groupStack.get(parentGroupHID)
1✔
584
                            if group <> invalid
3✔
585
                                group.setLastFocusedHID(m.globalFocusHID)
1✔
586
                            end if
587
                        end if
588
                    end if
589
                end if
590
            end if
591

592
            ' Apply focus state (focused/blurred) to the target item.
593
            focusItem.applyFocus(isFocused, enableNativeFocus)
1✔
594

595
            ' Update focus state for ancestor groups (blur groups losing focus, focus groups gaining focus)
596
            focusChainGroups = m.findAncestorGroups(focusItem.HID) ' Groups containing the new focus
1✔
597
            focusChainGroups.append(lastFocusChainingGroups) ' Include groups containing the old focus
1✔
598
            Rotor.Utils.removeRedundantValuesInArray(focusChainGroups) ' Unique list of affected groups
1✔
599
            m.notifyFocusAtAncestorGroups(focusItem.HID, focusChainGroups) ' Notify all relevant groups
1✔
600

601
            ' Update the globally tracked focused item.
602
            m.storeGlobalFocusHID(isFocused ? HID : "", isFocused ? focusItem.id : "")
1✔
603

604
            ' Ensure SceneGraph root has focus if native focus wasn't explicitly enabled on the item.
605
            if enableNativeFocus = false
3✔
606
                globalScope = GetGlobalAA()
1✔
607
                if globalScope.top.isInFocusChain() = false
2✔
608
                    globalScope.top.setFocus(true)
1✔
609
                end if
610
            end if
611

612
            return true
1✔
613

614
        end function
615

616
        '
617
        ' notifyFocusAtAncestorGroups - Applies the correct focus state (in focus chain or not) to a list of group HIDs
618
        '
619
        ' @param {string} HID - The HID of the item that ultimately received/lost focus
620
        ' @param {object} groupHIDs - An roArray of group HIDs to notify
621
        '
622
        sub notifyFocusAtAncestorGroups(HID as string, groupHIDs = [] as object)
623

624
            ' Notify all ancestor groups
625
            if groupHIDs.Count() > 0
3✔
626
                for each groupHID in groupHIDs
1✔
627

628
                    group = m.groupStack.get(groupHID)
1✔
629
                    isInFocusChain = Rotor.Utils.isAncestorHID(groupHID, HID)
1✔
630
                    group.applyFocus(isInFocusChain)
1✔
631

632
                end for
633
            end if
634
        end sub
635

636
        sub notifyLongPressAtAncestorGroups(isLongPress as boolean, key as string, HID as string, groupHIDs = [] as object)
637
            ' Notify all ancestor groups
638
            if groupHIDs.Count() > 0
×
639
                for each groupHID in groupHIDs
×
640
                    group = m.groupStack.get(groupHID)
×
641
                    handled = group.callLongPressHandler(isLongPress, key)
×
642
                    if handled then exit for
×
643
                end for
644
            end if
645
        end sub
646

647
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
648
            focused = m.getFocusedItem()
×
649
            handled = focused.callLongPressHandler(isLongPress, key)
×
650
            if handled then return
651

652
            focusChainGroups = m.findAncestorGroups(focused.HID)
×
653
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
×
654
        end sub
655

656
        function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object) as string
657
            if focused.enableSpatialNavigation = false then return ""
2✔
658
            if direction = Rotor.Const.Direction.BACK then return ""
2✔
659

660
            ' Remove current focused item from candidates
661
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
1✔
662
            if index >= 0 then focusItemsHIDlist.delete(index)
1✔
663

664
            ' Find closest focusable item in direction
665
            segments = m.collectSegments(focused, direction, focusItemsHIDlist)
1✔
666
            if segments.Count() > 0
3✔
667
                return m.findClosestSegment(segments, focused.metrics.middlePoint)
1✔
668
            end if
669

670
            return ""
1✔
671
        end function
672

673
        function findClosestSegment(segments as object, middlePoint as object) as string
674
            distances = []
1✔
675

676
            ' Calculate distance from middle point to each segment
677
            for each HID in segments
1✔
678
                segment = segments[HID]
1✔
679
                distance = m.distanceCalculator.distToSegment(middlePoint, {
1✔
680
                    x: segment.x1,
681
                    y: segment.y1
682
                }, {
683
                    x: segment.x2,
684
                    y: segment.y2
685
                })
686

687
                distances.push({
1✔
688
                    HID: HID,
689
                    distance: distance
690
                })
691
            end for
692

693
            ' Find segment with minimum distance
694
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
1✔
695
                return a < b
696
            end function)
697

698
            return minDistItem.HID
1✔
699
        end function
700

701

702
        ' Waterfall of fallback's of groups (linked together with defaultFocusId)
703
        function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
704
            ' Resolve identifier to a group
705
            group = m.groupStack.get(identifier)
1✔
706
            if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
1✔
707
            if group = invalid then return ""
2✔
708

709
            ' Get fallback identifier for this group
710
            newHID = group.getFallbackIdentifier()
1✔
711

712
            ' Check if we found a FocusItem
713
            if m.focusItemStack.has(newHID)
3✔
714
                ' Apply spatial enter feature if enabled
715
                if group.enableSpatialEnter = true and direction <> ""
2✔
716
                    focused = m.focusItemStack.get(m.globalFocusHID)
×
717
                    newSpatialHID = m.spatialNavigation(focused, direction, group.getGroupMembersHIDs())
×
718
                    if newSpatialHID <> "" then newHID = newSpatialHID
×
719
                end if
720

721
            else if newHID <> ""
3✔
722
                ' Try to find as group first, then deep search
723
                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
1✔
724

725
                ' If still not found, perform deep search in all descendants
726
                if newHID = ""
3✔
727
                    newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
1✔
728
                end if
729
            end if
730

731
            ' Prevent capturing by fallback in the same group where original focus was
732
            if newHID <> "" and m.globalFocusHID <> ""
3✔
733
                currentAncestors = m.findAncestorGroups(m.globalFocusHID)
1✔
734
                newAncestors = m.findAncestorGroups(newHID)
1✔
735
                if currentAncestors.Count() > 0 and newAncestors.Count() > 0
3✔
736
                    if currentAncestors[0] = newAncestors[0] then newHID = ""
1✔
737
                end if
738
            end if
739

740
            return newHID
1✔
741
        end function
742

743
        '
744
        ' deepSearchFocusItemByNodeId - Deep search for a FocusItem or Group by nodeId within a group hierarchy
745
        '
746
        ' @param {string} groupHID - The HID of the group to search within
747
        ' @param {string} nodeId - The node ID to search for
748
        ' @returns {string} The HID of the found FocusItem or Group, or empty string if not found
749
        '
750
        function deepSearchFocusItemByNodeId(groupHID as string, nodeId as string) as string
751
            if nodeId = "" then return ""
2✔
752

753
            ' Get all descendants of this group (both FocusItems and nested Groups)
754
            allFocusItems = m.focusItemStack.getAll()
1✔
755
            allGroups = m.groupStack.getAll()
1✔
756

757
            ' First, search in direct and nested FocusItems
758
            for each focusItemHID in allFocusItems
1✔
759
                if Rotor.Utils.isDescendantHID(focusItemHID, groupHID)
3✔
760
                    focusItem = m.focusItemStack.get(focusItemHID)
1✔
761
                    if focusItem <> invalid and focusItem.id = nodeId
2✔
762
                        return focusItemHID
×
763
                    end if
764
                end if
765
            end for
766

767
            ' Second, search in nested Groups (and if found, apply fallback logic on that group)
768
            for each nestedGroupHID in allGroups
1✔
769
                if Rotor.Utils.isDescendantHID(nestedGroupHID, groupHID) and nestedGroupHID <> groupHID
3✔
770
                    nestedGroup = m.groupStack.get(nestedGroupHID)
1✔
771
                    if nestedGroup <> invalid and nestedGroup.id = nodeId
3✔
772
                        ' Found a matching group - now apply fallback logic on it
773
                        fallbackHID = nestedGroup.getFallbackIdentifier()
1✔
774
                        if m.focusItemStack.has(fallbackHID)
3✔
775
                            return fallbackHID
1✔
776
                        else if fallbackHID <> ""
×
777
                            ' Recursively resolve the fallback
778
                            return m.capturingFocus_recursively(fallbackHID, "", nestedGroupHID)
×
779
                        end if
780
                    end if
781
                end if
782
            end for
783

784
            return ""
×
785
        end function
786

787
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
788
            newHID = ""
1✔
789

790
            ' Build ancestor chain (current group + all ancestors)
791
            ancestorGroups = m.findAncestorGroups(groupHID)
1✔
792
            ancestorGroups.unshift(groupHID)
1✔
793
            ancestorGroupsCount = ancestorGroups.Count()
1✔
794
            ancestorIndex = 0
1✔
795

796
            ' Bubble up through ancestor groups until we find a target or reach the top
797
            while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
1✔
798
                ' Get next ancestor group
799
                groupHID = ancestorGroups[ancestorIndex]
1✔
800
                group = m.groupStack.get(groupHID)
1✔
801

802
                ' Check group's direction configuration
803
                nodeId = group.getStaticNodeIdInDirection(direction)
1✔
804

805
                if Rotor.Utils.isBoolean(nodeId)
2✔
806
                    ' Boolean means focus is explicitly handled
807
                    if nodeId = true
3✔
808
                        newHID = true ' Block navigation (exit loop)
1✔
809
                    else
×
810
                        newHID = "" ' Continue bubbling
×
811
                    end if
812
                else
813
                    ' String nodeId - try to resolve target
3✔
814
                    if nodeId <> ""
3✔
815
                        otherGroup = m.groupStack.getByNodeId(nodeId)
1✔
816
                        if otherGroup <> invalid
3✔
817
                            newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
1✔
818
                        end if
819
                    end if
820
                end if
821

822
                ancestorIndex++
1✔
823
            end while
824

825
            return newHID
1✔
826
        end function
827

828
        ' * KEY EVENT HANDLER
829
        function onKeyEventHandler(key as string, press as boolean) as object
830
            ' Check long-press
831
            if m.enableLongPressFeature = true
2✔
832
                m.checkLongPressState(key, press)
×
833
            end if
834
            ' Prevent any navigation if it is disabled
835
            #if debug
4✔
836
                if m.enableFocusNavigation = false and press = true then print "[PLUGIN][FOCUS][INFO] Focus navigation is disabled. Call enableFocusNavigation(true) to make it enabled"
2✔
837
            #end if
838
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
2✔
839
            ' Execute action according to key press
840
            return m.executeNavigationAction(key, press)
1✔
841
        end function
842

843
        function executeNavigationAction(key as string, press as boolean) as object
844

845
            if true = press
3✔
846

847
                if -1 < Rotor.Utils.findInArray([
3✔
848
                        Rotor.Const.Direction.UP,
849
                        Rotor.Const.Direction.RIGHT,
850
                        Rotor.Const.Direction.DOWN,
851
                        Rotor.Const.Direction.LEFT,
852
                        Rotor.Const.Direction.BACK
853
                    ], key)
854

855
                    newHID = ""
1✔
856
                    direction = key
1✔
857

858
                    ' (1) Pick up current focused item
859

860
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
861

862
                    if focused = invalid
2✔
863
                        #if debug
×
864
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue detected. Last known focus id:\"${m.globalFocusHID}\". Please ensure valid focus.`
×
865
                        #end if
866
                        return m.parseOnKeyEventResult(key, false, false)
×
867
                    end if
868

869

870
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
871
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
872

873
                    if ancestorGroupsCount = 0
2✔
874
                        allFocusItems = m.focusItemStack.getAll()
×
875
                        possibleFocusItems = allFocusItems.keys()
×
876
                        parentGroupHID = ""
×
877
                    else
3✔
878
                        parentGroupHID = ancestorGroups[0]
1✔
879
                        group = m.groupStack.get(parentGroupHID)
1✔
880
                        possibleFocusItems = group.getGroupMembersHIDs()
1✔
881
                    end if
882

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

886
                    if Rotor.Utils.isBoolean(nodeId) and nodeId = true
2✔
887
                        ' It means that focus is handled, and no need further action by plugin.
888
                        return m.parseOnKeyEventResult(key, true, false)
×
889
                    end if
890

891
                    if nodeId <> ""
2✔
892
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
893
                    end if
894

895
                    if newHID = ""
3✔
896
                        ' (3) Try spatial navigation in direction, among possible focusItems
897
                        ' all = m.focusItemStack.getAll()
898
                        ' allKeys = all.Keys()
899
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
1✔
900
                    end if
901

902
                    ' (4) Check if found group. FocusItem can not point out of group.
903
                    if newHID = "" and ancestorGroupsCount > 0 ' (5/2) If this focused has parent group, lets try bubbling focus on ancestors (groups)
2✔
904
                        newHID = m.bubblingFocus(parentGroupHID, direction)
1✔
905
                        if Rotor.Utils.isBoolean(newHID)
2✔
906
                            if newHID = true
3✔
907
                                ' It means that focus is handled, and no need further action by plugin.
908
                                return m.parseOnKeyEventResult(key, true, false)
1✔
909
                            else
×
910
                                newHID = ""
×
911
                            end if
912
                        end if
913
                    end if
914

915
                    handled = m.setFocus(newHID)
1✔
916
                    return m.parseOnKeyEventResult(key, handled, false)
1✔
917

918
                else if key = "OK"
×
919

920
                    return m.parseOnKeyEventResult(key, true, true)
×
921

922
                end if
923
            end if
924

925
            return m.parseOnKeyEventResult(key, false, false)
×
926

927
        end function
928

929
        function parseOnKeyEventResult(key as string, handled as boolean, isSelected as boolean) as object
930
            result = {
1✔
931
                handled: handled,
932
                key: key
933
            }
934
            if m.globalFocusHID <> "" and handled = true
3✔
935
                focusItem = m.focusItemStack.get(m.globalFocusHID)
1✔
936
                widget = m.widgetTree.get(focusItem.HID)
1✔
937
                ' viewModelState = Rotor.Utils.deepCopy(widget.viewModelState)
938
                result.widget = widget
1✔
939
                if isSelected
2✔
940
                    result.isSelected = isSelected
×
941
                    focusItem.callOnSelectedFnOnWidget()
×
942
                end if
943
            end if
944
            return result
1✔
945
        end function
946

947
        sub checkLongPressState(key as string, press as boolean)
948
            m.longPressTimer.control = "stop"
×
949
            if press = true
×
950
                if m.isLongPress = false
×
951
                    m.longPressKey = key
×
952
                    m.longPressTimer.control = "start"
×
953
                end if
954
            else
×
955
                wasLongPress = m.isLongPress = true
×
956
                lastKey = m.longPressKey
×
957
                m.isLongPress = false
×
958
                m.longPressKey = ""
×
959
                if wasLongPress
×
960
                    m.delegateLongPressChanged(false, lastKey)
×
961
                end if
962
            end if
963
        end sub
964

965
        function proceedLongPress() as object
966
            return m.executeNavigationAction(m.longPressKey, true)
×
967
        end function
968

969
        ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
970
        function collectSegments(focused as object, direction as string, focusItemsHIDlist as object) as object
971
            focused.refreshBounding()
1✔
972

973
            refSegmentTop = focused.metrics.segments[Rotor.Const.Segment.TOP]
1✔
974
            refSegmentRight = focused.metrics.segments[Rotor.Const.Segment.RIGHT]
1✔
975
            referencePoint = { x: (refSegmentTop.x1 + refSegmentRight.x2) / 2, y: (refSegmentTop.y1 + refSegmentRight.y2) / 2 }
1✔
976

977
            validators = {
1✔
978

979
                "left": function(referencePoint as object, segments as object) as object
980
                    right = segments[Rotor.Const.Segment.RIGHT]
981
                    ' stop
982
                    return right.x1 <= referencePoint.x ? { isValid: true, segment: right } : { isValid: false }
983
                end function,
984

985
                "up": function(referencePoint as object, segments as object) as object
986
                    bottom = segments[Rotor.Const.Segment.BOTTOM]
987
                    ' stop
988
                    return bottom.y1 <= referencePoint.y ? { isValid: true, segment: bottom } : { isValid: false }
989
                end function,
990

991
                "right": function(referencePoint as object, segments as object) as object
992
                    left = segments[Rotor.Const.Segment.LEFT]
993
                    ' stop
994
                    return left.x1 >= referencePoint.x ? { isValid: true, segment: left } : { isValid: false }
995
                end function,
996

997
                "down": function(referencePoint as object, segments as object) as object
998
                    top = segments[Rotor.Const.Segment.TOP]
999
                    ' stop
1000
                    return top.y1 >= referencePoint.y ? { isValid: true, segment: top } : { isValid: false }
1001
                end function
1002
            }
1003
            segments = {}
1✔
1004
            validator = validators[direction]
1✔
1005
            for each HID in focusItemsHIDlist
1✔
1006
                if HID <> focused.HID
3✔
1007
                    focusItem = m.focusItemStack.get(HID)
1✔
1008
                    focusItem.refreshBounding()
1✔
1009
                    result = validator(referencePoint, focusItem.metrics.segments)
1✔
1010
                    if result.isValid
3✔
1011
                        segments[HID] = result.segment
1✔
1012
                    end if
1013
                end if
1014
            end for
1015

1016
            return segments
1✔
1017
        end function
1018

1019
        sub destroy()
1020
            ' Remove all groups
1021
            for each HID in m.groupStack.getAll()
×
1022
                m.groupStack.remove(HID)
×
1023
            end for
1024
            ' Remove all focus items
1025
            for each HID in m.focusItemStack.getAll()
×
1026
                m.focusItemStack.remove(HID)
×
1027
            end for
1028
            m.longPressTimer.unobserveFieldScoped("fire")
×
1029
            m.longPressTimer = invalid
×
1030
            m.widgetTree = invalid
×
1031
        end sub
1032

1033
    end class
1034

1035
    namespace FocusPluginHelper
1036

1037
        class BaseEntryStack extends Rotor.BaseStack
1038

1039
            function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
1040
                if ancestorHID <> "0"
3✔
1041
                    filteredStack = {}
1✔
1042
                    for each HID in m.stack
1✔
1043
                        if Rotor.Utils.isDescendantHID(HID, ancestorHID)
2✔
1044
                            filteredStack[HID] = m.get(HID)
1✔
1045
                        end if
1046
                    end for
1047
                else
3✔
1048
                    filteredStack = m.stack
1✔
1049
                end if
1050
                HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
1✔
1051
                return HID <> "" ? m.get(HID) : invalid
1✔
1052
            end function
1053

1054
            override sub remove(HID as string)
1055
                item = m.get(HID)
1✔
1056
                item.destroy()
1✔
1057
                super.remove(HID)
1✔
1058
            end sub
1059

1060
        end class
1061

1062
        class GroupStack extends BaseEntryStack
1063

1064
            function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1065
                foundHID = ""
×
1066
                for each HID in possibleGroups
×
1067
                    group = m.get(HID)
×
1068
                    if group.id = nodeId
×
1069
                        foundHID = group.HID
×
1070
                        exit for
1071
                    end if
1072
                end for
1073
                return foundHID
×
1074
            end function
1075

1076
        end class
1077

1078

1079
        class FocusItemStack extends BaseEntryStack
1080

1081
            function convertNodeIdToHID(nodeId as string, possibleFocusItems as object) as string
1082
                foundHID = ""
×
1083
                for each HID in possibleFocusItems
×
1084
                    focusItem = m.get(HID)
×
1085
                    if focusItem.id = nodeId
×
1086
                        foundHID = focusItem.HID
×
1087
                        exit for
1088
                    end if
1089
                end for
1090
                return foundHID
×
1091
            end function
1092

1093
            function hasEnabled(HID as string) as boolean
1094
                if m.has(HID)
×
1095
                    focusItem = m.get(HID)
×
1096
                    return focusItem.isEnabled
×
1097
                else
×
1098
                    return false
×
1099
                end if
1100
            end function
1101

1102
        end class
1103

1104
        class BaseFocusConfig
1105

1106
            autoSetIsFocusedOnContext as boolean
1107
            staticDirection as object
1108

1109
            sub new (config as object)
1110

1111
                m.HID = config.HID
1✔
1112
                m.id = config.id
1✔
1113

1114
                m.widget = config.widget
1✔
1115
                m.node = m.widget.node
1✔
1116
                m.isFocused = config.isFocused ?? false
1✔
1117

1118
                m.autoSetIsFocusedOnContext = config.autoSetIsFocusedOnContext ?? true
1✔
1119

1120
                m.isEnabled = config.isEnabled ?? true
1✔
1121
                m.staticDirection = {}
1✔
1122
                m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1✔
1123
                m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
1✔
1124
                m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
1✔
1125
                m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
1✔
1126
                m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
1✔
1127

1128
                m.onFocusChanged = config.onFocusChanged
1✔
1129
                m.longPressHandler = config.longPressHandler
1✔
1130
                m.onFocus = config.onFocus
1✔
1131

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

1134
                ' convenience (usually this is used on viewModelState)
1135
                if false = m.widget.viewModelState.DoesExist("isFocused") and true = m.autoSetIsFocusedOnContext
2✔
1136
                    m.widget.viewModelState.isFocused = false ' as default
1✔
1137
                end if
1138

1139
            end sub
1140

1141

1142
            HID as string
1143
            id as string
1144
            idByKeys as object
1145
            isEnabled as boolean
1146
            isFocused as boolean
1147
            onFocusChanged as dynamic
1148
            onFocus as dynamic
1149
            longPressHandler as dynamic
1150
            node as object
1151
            widget as object
1152

1153
            function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1154
                direction = m.staticDirection[direction]
1✔
1155
                if Rotor.Utils.isFunction(direction)
2✔
1156
                    return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
×
1157
                else
3✔
1158
                    return direction ?? ""
1✔
1159
                end if
1160
            end function
1161

1162
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1163
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1164
                if true = isFocused
3✔
1165
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1166
                end if
1167
            end sub
1168

1169
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1170
                if Rotor.Utils.isFunction(m.longPressHandler)
×
1171
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1172
                else
×
1173
                    return false
×
1174
                end if
1175
            end function
1176

1177
            sub destroy()
1178
                m.widget = invalid
1✔
1179
                m.node = invalid
1✔
1180
                m.onFocusChanged = invalid
1✔
1181
                m.longPressHandler = invalid
1✔
1182
            end sub
1183

1184
        end class
1185

1186
        class GroupClass extends BaseFocusConfig
1187
            ' Note: Spatial navigation is supported within group, there is no spatial navigation between groups
1188
            ' If you want to focus out to another group, you need to config a direction prop.
1189
            ' You can set a groupId or any focusItem widgetId.
1190
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1191
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1192

1193
            sub new (config as object)
1194
                super(config)
1✔
1195
                m.defaultFocusId = config.defaultFocusId ?? ""
1✔
1196
                m.lastFocusedHID = config.lastFocusedHID ?? ""
1✔
1197
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
1✔
1198
            end sub
1199

1200
            defaultFocusId as string
1201
            lastFocusedHID as string
1202
            enableSpatialEnter as boolean
1203
            focusItemsRef as object
1204
            groupsRef as object
1205

1206
            isFocusItem = false
1207
            isGroup = true
1208

1209
            sub setLastFocusedHID(lastFocusedHID as string)
1210
                m.lastFocusedHID = lastFocusedHID
1✔
1211
            end sub
1212

1213
            function getGroupMembersHIDs()
1214
                ' Collect all focusItems that are descendants of this group
1215
                ' Exclude items that belong to nested sub-groups
1216
                focusItems = m.focusItemsRef.getAll()
1✔
1217
                groups = m.groupsRef.getAll()
1✔
1218
                HIDlen = Len(m.HID)
1✔
1219
                collection = []
1✔
1220
                groupsKeys = groups.keys()
1✔
1221
                groupsCount = groups.Count()
1✔
1222

1223
                for each focusItemHID in focusItems
1✔
1224
                    ' Check if focusItem is a descendant of this group
1225
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
1✔
1226
                    if isDescendant
2✔
1227
                        ' Check if focusItem belongs to a nested sub-group
1228
                        shouldExclude = false
1✔
1229
                        otherGroupIndex = 0
1✔
1230
                        while shouldExclude = false and otherGroupIndex < groupsCount
1✔
1231
                            otherGroupHID = groupsKeys[otherGroupIndex]
1✔
1232
                            otherGroupHIDlen = Len(otherGroupHID)
1✔
1233
                            ' Exclude if belongs to deeper nested group
1234
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
1✔
1235
                            otherGroupIndex++
1✔
1236
                        end while
1237

1238
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1239
                    end if
1240
                end for
1241

1242
                return collection
1✔
1243
            end function
1244

1245
            '
1246
            ' getFallbackNodeId - Returns the nodeId to use for fallback (defaultFocusId or lastFocusedHID)
1247
            '
1248
            ' @returns {string} The nodeId to use for fallback, or empty string if none
1249
            '
1250
            function getFallbackNodeId() as string
1251
                if m.lastFocusedHID <> ""
2✔
1252
                    ' Note: lastFocusedHID is already a HID, not a nodeId, so we need to get the nodeId
1253
                    lastFocusedItem = m.focusItemsRef.get(m.lastFocusedHID)
×
1254
                    if lastFocusedItem <> invalid
×
1255
                        return lastFocusedItem.id
×
1256
                    end if
1257
                end if
1258

1259
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1260
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1261
                else
3✔
1262
                    return m.defaultFocusId
1✔
1263
                end if
1264
            end function
1265

1266
            function getFallbackIdentifier() as string
1267
                HID = ""
1✔
1268
                if m.lastFocusedHID <> ""
2✔
1269
                    return m.lastFocusedHID
×
1270
                else
3✔
1271
                    if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1272
                        defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1273
                    else
3✔
1274
                        defaultFocusId = m.defaultFocusId
1✔
1275
                    end if
1276

1277
                    if defaultFocusId <> ""
3✔
1278
                        focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1279
                        if focusItemsHIDlist.Count() > 0
3✔
1280

1281
                            ' Try find valid HID in focusItems by node id
1282
                            focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1✔
1283
                            if focusItemHID <> ""
3✔
1284
                                HID = focusItemHID
1✔
1285
                            end if
1286

1287
                        else
1288

3✔
1289
                            return defaultFocusId
1✔
1290

1291
                        end if
1292
                    end if
1293

1294
                end if
1295

1296
                return HID
1✔
1297
            end function
1298

1299
            function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1300
                HID = ""
1✔
1301
                for each HID in focusItemsHIDlist
1✔
1302
                    focusItem = m.focusItemsRef.get(HID)
1✔
1303
                    if focusItem <> invalid and focusItem.id = nodeId
3✔
1304
                        HID = focusItem.HID
1✔
1305
                        exit for
1306
                    end if
1307
                end for
1308
                return HID
1✔
1309
            end function
1310

1311
            sub applyFocus(isFocused as boolean)
1312
                if m.isFocused = isFocused then return
2✔
1313

1314
                m.isFocused = isFocused
1✔
1315

1316
                if m.autoSetIsFocusedOnContext
3✔
1317
                    m.widget.viewModelState.isInFocusChain = isFocused
1✔
1318
                end if
1319
                m.node.setField("isFocused", isFocused)
1✔
1320
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1321
            end sub
1322

1323
            override sub destroy()
1324
                super.destroy()
1✔
1325
                m.focusItemsRef = invalid
1✔
1326
                m.groupsRef = invalid
1✔
1327
            end sub
1328

1329

1330

1331
        end class
1332

1333
        class FocusItemClass extends BaseFocusConfig
1334

1335
            sub new (config as object)
1336
                super(config)
1✔
1337

1338
                m.onSelected = config.onSelected ?? ""
1✔
1339
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
1✔
1340
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1341
            end sub
1342

1343
            ' You can set a groupId or any focusItem widgetId.
1344
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1345
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1346

1347
            ' key as string
1348
            isFocusItem = true
1349
            isGroup = false
1350
            enableNativeFocus as boolean
1351
            enableSpatialNavigation as boolean
1352
            onSelected as dynamic
1353

1354
            private metrics = {
1355
                segments: {}
1356
            }
1357
            private bounding as object
1358

1359

1360
            sub refreshBounding()
1361
                b = m.node.sceneBoundingRect()
1✔
1362
                rotation = m.node.rotation
1✔
1363

1364
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1365
                ' That is why we can use translation without knowing the value of inheritParentTransform
1366
                ' If bounding x or y are not zero, then bounding will include the node's translation
1367
                if rotation = 0
3✔
1368
                    if b.y = 0 and b.x = 0
2✔
1369
                        t = m.node.translation
×
1370
                        b.x += t[0]
×
1371
                        b.y += t[1]
×
1372
                    end if
1373

1374
                    m.metrics.append(b)
1✔
1375
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
1✔
1376
                        x1: b.x, y1: b.y,
1377
                        x2: b.x, y2: b.y + b.height
1378
                    }
1379
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
1✔
1380
                        x1: b.x, y1: b.y,
1381
                        x2: b.x + b.width, y2: b.y
1382
                    }
1383
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
1✔
1384
                        x1: b.x + b.width, y1: b.y,
1385
                        x2: b.x + b.width, y2: b.y + b.height
1386
                    }
1387
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
1✔
1388
                        x1: b.x, y1: b.y + b.height,
1389
                        x2: b.x + b.width, y2: b.y + b.height
1390
                    }
1391
                    m.metrics.middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
1✔
1392
                else
×
1393
                    scaleRotateCenter = m.node.scaleRotateCenter
×
1394
                    dims = m.node.localBoundingRect() ' We need this to get proper (rotated value of rotated x and y)
×
1395
                    if b.y = 0 and b.x = 0
×
1396
                        t = m.node.translation
×
1397
                        b.x += t[0]
×
1398
                        b.y += t[1]
×
1399
                    end if
1400
                    b.width = dims.width
×
1401
                    b.height = dims.height
×
1402
                    m.metrics.append(b)
×
1403

1404
                    ' Calculate rotated segments
1405
                    segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
×
1406
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1407
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
×
1408

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

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

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

1421
                    ' Calculate rotated middle point
1422
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1423
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1424
                    m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
×
1425

1426
                end if
1427
            end sub
1428

1429
            override sub destroy()
1430
                m.onSelected = invalid
1✔
1431
                m.metrics.segments.Clear()
1✔
1432
                super.destroy()
1✔
1433
            end sub
1434

1435
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1436
                if m.isFocused = isFocused then return
2✔
1437

1438
                m.isFocused = isFocused
1✔
1439

1440
                if m.autoSetIsFocusedOnContext
3✔
1441
                    m.widget.viewModelState.isFocused = isFocused
1✔
1442
                end if
1443

1444
                m.node.setField("isFocused", isFocused)
1✔
1445

1446
                if enableNativeFocus or m.enableNativeFocus
2✔
1447
                    m.node.setFocus(isFocused)
×
1448
                end if
1449

1450
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1451

1452
            end sub
1453

1454
            sub callOnSelectedFnOnWidget()
1455
                Rotor.Utils.callbackScoped(m.onSelected, m.widget)
×
1456
            end sub
1457

1458
        end class
1459

1460
        class ClosestSegmentToPointCalculatorClass
1461

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

1465
                A = x - x1
1✔
1466
                B = y - y1
1✔
1467
                C = x2 - x1
1✔
1468
                D = y2 - y1
1✔
1469

1470
                dot = A * C + B * D
1✔
1471
                len_sq = C * C + D * D
1✔
1472
                param = -1
1✔
1473
                if len_sq <> 0
3✔
1474
                    param = dot / len_sq
1✔
1475
                end if
1476

1477
                xx = 0
1✔
1478
                yy = 0
1✔
1479

1480
                if param < 0
2✔
1481
                    xx = x1
×
1482
                    yy = y1
×
1483
                else if param > 1
2✔
1484
                    xx = x2
×
1485
                    yy = y2
×
1486
                else
3✔
1487
                    xx = x1 + param * C
1✔
1488
                    yy = y1 + param * D
1✔
1489
                end if
1490

1491
                dx = x - xx
1✔
1492
                dy = y - yy
1✔
1493
                return dx * dx + dy * dy
1✔
1494
            end function
1495

1496
            function distToSegment(p as object, s1 as object, s2 as object)
1497
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1498
            end function
1499

1500
        end class
1501

1502
    end namespace
1503

1504
    namespace FocusPluginHelper
1505

1506
        sub longPressObserverCallback(msg)
1507
            extraInfo = msg.GetInfo()
×
1508

1509
            pluginKey = extraInfo["pluginKey"]
×
1510

1511
            globalScope = GetGlobalAA()
×
1512
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
×
1513
            plugin = frameworkInstance.plugins[pluginKey]
×
1514
            plugin.isLongPress = true
×
1515
            ' plugin.longPressStartHID = plugin.globalFocusHID
1516
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
×
1517

1518
        end sub
1519

1520
    end namespace
1521

1522
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