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

mobalazs / rotor-framework / 20104457694

10 Dec 2025 03:44PM UTC coverage: 84.974% (+15.7%) from 69.231%
20104457694

push

github

web-flow
Feat/tts support (#12)

* feat: Add TTS (Text-to-Speech) service to Rotor Framework

* feat: Update i18n and TTS services for improved functionality and string interpolation support

* Add comprehensive tests for framework lifecycle, focus plugin, i18n service, and TTS integration

- Implement tests for framework lifecycle to ensure proper resource cleanup on destroy.
- Enhance focus plugin tests with utility methods, navigation toggling, and cleanup verification.
- Introduce a new suite for i18n service covering basic integration, locale switching, cache management, and edge cases.
- Add extensive TTS service tests, validating method accessibility, speech processing, and state management.

* fix: Update localization setup in widget creation to use ln10 reference

* refactor: Update focus handling and i18n service integration in widget creation and tests

* refactor: Update focus handling and TTS service integration in FocusPlugin and related tests

* fix: Correct field value check in setupField method of ObserverPlugin

* test: Add unit tests for DispatcherProviderPlugin and ObserverPlugin lifecycle

* refactor: Update widget method descriptions and access patterns in i18n, TTS, and Focus plugins

169 of 263 new or added lines in 7 files covered. (64.26%)

3 existing lines in 2 files now uncovered.

1968 of 2316 relevant lines covered (84.97%)

1.17 hits per line

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

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

4
namespace Rotor
5

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

167
    const PRIMARY_FOCUS_PLUGIN_KEY = "focus"
168
    const GROUP_FOCUS_PLUGIN_KEY = "focusGroup"
169
    class FocusPlugin extends Rotor.BasePlugin
170

171
        pluginKey = PRIMARY_FOCUS_PLUGIN_KEY
172
        aliasPluginKey = GROUP_FOCUS_PLUGIN_KEY
173

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

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

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

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

214
                ' Merge new config into existing widget config
215
                Rotor.Utils.deepExtendAA(widget[scope.pluginKey], newValue)
×
216
                scope.setFocusConfig(widget, widget[scope.pluginKey])
×
217
            end sub,
218

219
            ' ---------------------------------------------------------------------
220
            ' beforeDestroy - Hook executed before a widget is destroyed
221
            '
222
            ' Removes focus config.
223
            '
224
            ' @param {object} scope - The plugin scope (this instance)
225
            ' @param {object} widget - The widget being destroyed
226
            '
227
            beforeDestroy: sub(scope as object, widget as object)
228
                scope.removeFocusConfig(widget.HID)
1✔
229
            end sub
230
        }
231

232
        ' Widget methods - Injected into widgets managed by this plugin
233
        widgetMethods = {
234

235
            ' ---------------------------------------------------------------------
236
            ' enableFocusNavigation - Enables or disables focus navigation globally for this plugin
237
            '
238
            ' @param {boolean} enableFocusNavigation - True to enable, false to disable (default: true)
239
            '
240
            enableFocusNavigation: sub(enableFocusNavigation = true as boolean)
241
                m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].enableFocusNavigation = enableFocusNavigation
1✔
242
            end sub,
243

244
            ' ---------------------------------------------------------------------
245
            ' isFocusNavigationEnabled - Checks if focus navigation is currently enabled globally
246
            '
247
            ' @returns {boolean} True if enabled, false otherwise
248
            '
249
            isFocusNavigationEnabled: function() as boolean
250
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].enableFocusNavigation
1✔
251
            end function,
252

253
            ' ---------------------------------------------------------------------
254
            ' setFocus - Sets focus to this widget or another specified widget
255
            '
256
            ' @param {dynamic} isFocused - Boolean to focus/blur current widget, or string ID/HID of widget to focus
257
            ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node
258
            ' @returns {boolean} True if focus state was changed successfully, false otherwise
259
            '
260
            setFocus: function(command = true as dynamic, enableNativeFocus = false as boolean) as boolean
261
                plugin = m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY]
1✔
262
                HID = m.HID
1✔
263

264
                if Rotor.Utils.isString(command)
2✔
265
                    return plugin.setFocus(command, true, enableNativeFocus)
1✔
266
                else
3✔
267
                    return plugin.setFocus(HID, command, enableNativeFocus)
1✔
268
                end if
269
            end function,
270

271
            ' ---------------------------------------------------------------------
272
            ' getFocusedWidget - Retrieves the currently focused widget managed by this plugin
273
            '
274
            ' @returns {object} The widget instance that currently holds focus, or invalid
275
            '
276
            getFocusedWidget: function() as object
277
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].getFocusedWidget()
1✔
278
            end function,
279

280
            ' ---------------------------------------------------------------------
281
            ' proceedLongPress - Manually triggers the navigation action associated with the current long-press key
282
            '
283
            ' @returns {object} The result of the executed navigation action (see parseOnKeyEventResult)
284
            '
285
            proceedLongPress: function() as object
286
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].proceedLongPress()
×
287
            end function,
288

289
            ' ---------------------------------------------------------------------
290
            ' isLongPressActive - Checks if a long press action is currently active
291
            '
292
            ' @returns {boolean} True if a long press is active, false otherwise
293
            '
294
            isLongPressActive: function() as boolean
295
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].isLongPress
×
296
            end function,
297

298
            ' ---------------------------------------------------------------------
299
            ' triggerKeyPress - Simulate key press
300
            '
301
            ' @param {string} key - Pressed key
302
            ' @returns {object} The widget instance that currently holds focus, or invalid
303
            '
304
            triggerKeyPress: function(key) as object
305
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].onKeyEventHandler(key, true)
1✔
306
            end function
307

308
        }
309

310
        ' Configuration
311
        longPressDuration = 0.4
312
        enableLongPressFeature = true
313
        enableFocusNavigation = true
314

315
        ' State tracking
316
        globalFocusHID = ""
317
        globalFocusId = ""
318
        isLongPress = false
319
        longPressKey = ""
320

321
        ' References
322
        widgetTree as object
323
        frameworkInstance as Rotor.Framework
324

325
        ' Helper objects
326
        focusItemStack = new Rotor.FocusPluginHelper.FocusItemStack()
327
        groupStack = new Rotor.FocusPluginHelper.GroupStack()
328
        distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass()
329
        longPressTimer = CreateObject("roSGNode", "Timer")
330

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

347
        '
348
        ' storeGlobalFocusHID - Stores the globally focused widget's HID and ID
349
        '
350
        ' @param {string} HID - The Hierarchical ID of the focused widget
351
        ' @param {string} id - The regular ID of the focused widget
352
        '
353
        sub storeGlobalFocusHID(HID as string, id as string)
354
            ' Store focus reference within the plugin
355
            m.globalFocusHID = HID
1✔
356
            m.globalFocusId = id
1✔
357
        end sub
358

359
        '
360
        ' getFocusedWidget - Gets the widget instance that currently holds global focus
361
        '
362
        ' @returns {object} The focused widget object, or invalid if none
363
        '
364
        function getFocusedWidget() as object
365
            return m.getFocusedItem()?.widget
1✔
366
        end function
367

368
        '
369
        ' getFocusedItem - Gets the FocusItem instance corresponding to the globally focused widget
370
        '
371
        ' @returns {object} The FocusItem instance, or invalid if none
372
        '
373
        function getFocusedItem() as object
374
            return m.focusItemStack.get(m.globalFocusHID)
1✔
375
        end function
376

377
        '
378
        ' setFocusConfig - Configures focus properties (FocusItem and/or Group) for a widget
379
        '
380
        ' @param {object} widget - The widget to configure
381
        ' @param {object} pluginConfig - The focus configuration object from the widget's spec
382
        '
383
        sub setFocusConfig(widget as object, pluginConfig as object)
384

385
            if pluginConfig = invalid then return ' No config provided
2✔
386
            HID = widget.HID
1✔
387
            id = widget.id
1✔
388

389
            ' Make a copy to avoid modifying the original config
390
            config = Rotor.Utils.deepCopy(pluginConfig)
1✔
391

392
            ' Ensure essential identifiers are in the config
393
            config.id = id
1✔
394
            config.HID = widget.HID
1✔
395

396
            ' Handle group configuration if present
397
            if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY)
3✔
398
                ' Handle focus item configuration if applicable
399
                m.setupFocusItem(HID, config, widget)
1✔
400
            else
401
                ' Handle group configuration
3✔
402
                m.setupGroup(HID, config, widget)
1✔
403
            end if
404
        end sub
405

406
        '
407
        ' setupGroup - Creates and registers a new Focus Group based on configuration
408
        '
409
        ' @param {string} HID - The Hierarchical ID of the widget acting as the group root
410
        ' @param {object} config - The full focus configuration for the widget
411
        ' @param {object} widget - The widget instance itself
412
        '
413
        sub setupGroup(HID as string, config as object, widget as object)
414
            ' Copy essential info to the group-specific config
415
            config.id = config.id
1✔
416
            config.HID = config.HID
1✔
417
            config.widget = widget
1✔
418
            ' Create and configure the Group instance
419
            newGroup = new Rotor.FocusPluginHelper.GroupClass(config)
1✔
420
            newGroup.focusItemsRef = m.focusItemStack ' Provide reference to focus items
1✔
421
            newGroup.groupsRef = m.groupStack ' Provide reference to other groups
1✔
422
            m.groupStack.set(config.HID, newGroup) ' Register the new group
1✔
423
        end sub
424

425
        '
426
        ' setupFocusItem - Creates and registers a new Focus Item based on configuration
427
        '
428
        ' @param {string} HID - The Hierarchical ID of the focusItem widget
429
        ' @param {object} config - The full focus configuration for the widget
430
        ' @param {object} widget - The widget instance itself
431
        '
432
        sub setupFocusItem(HID as string, config as object, widget as object)
433
            config.widget = widget ' Ensure widget reference is in the config
1✔
434

435
            ' Create and register the FocusItem instance
436
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
1✔
437
            m.focusItemStack.set(HID, newFocusItem)
1✔
438
        end sub
439

440
        '
441
        ' findAncestorGroups - Finds all ancestor groups for a given widget HID
442
        '
443
        ' @param {string} HID - The Hierarchical ID of the widget
444
        ' @returns {object} An roArray of ancestor group HIDs, sorted with the immediate parent first (descending HID length)
445
        '
446
        function findAncestorGroups(HID as string) as object
447
            allGroups = m.groupStack.getAll() ' Get all registered groups
1✔
448
            ancestorGroups = []
1✔
449
            ' Iterate through all groups to find ancestors
450
            for each groupHID in allGroups
1✔
451
                if Rotor.Utils.isAncestorHID(groupHID, HID)
3✔
452
                    ancestorGroups.push(groupHID)
1✔
453
                end if
454
            end for
455
            ' Sort by HID length descending (parent first)
456
            ancestorGroups.Sort("r")
1✔
457

458
            ' Note:
459
            ' - Parent group is at index 0
460
            ' - If HID is a focusItem, its direct parent group is included
461
            ' - If HID is a group, the group itself is NOT included
462
            return ancestorGroups
1✔
463
        end function
464

465
        '
466
        ' removeFocusConfig - Removes focus configuration (Group and/or FocusItem) for a widget
467
        '
468
        ' @param {string} HID - The Hierarchical ID of the widget whose config should be removed
469
        '
470
        sub removeFocusConfig(HID as string)
471
            ' Remove associated group, if it exists
472
            if m.groupStack.has(HID)
2✔
473
                m.groupStack.remove(HID)
1✔
474
            end if
475
            ' Remove associated focus item, if it exists
476
            if m.focusItemStack.has(HID)
2✔
477
                m.focusItemStack.remove(HID)
1✔
478
            end if
479
        end sub
480

481
        '
482
        ' setFocus - Sets or removes focus from a specific widget or group
483
        '
484
        ' Handles focus state changes, callbacks, and native focus interaction.
485
        '
486
        ' @param {dynamic} ref - The target: HID (string) of a FocusItem or Group, or Node ID (string) of a Group
487
        ' @param {boolean} isFocused - True to set focus, false to remove focus (default: true)
488
        ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node (default: false)
489
        ' @returns {boolean} True if the focus state was successfully changed, false otherwise
490
        '
491
        function setFocus(ref as dynamic, isFocused = true as boolean, enableNativeFocus = false as boolean) as boolean
492

493
            ' Resolve reference (HID or ID) to a focusItem item.
494
            focusItem = invalid ' Initialize target focus item
1✔
495

496
            ' Exit if reference is empty or invalid.
497
            if ref = invalid or ref = "" then return false
2✔
498

499
            if m.focusItemStack.has(ref)
2✔
500
                ' Case 1: ref is a valid focusItem HID.
501
                focusItem = m.focusItemStack.get(ref)
1✔
502
            else
503
                ' Case 2: ref might be a focusItem node ID.
3✔
504
                focusItem = m.focusItemStack.getByNodeId(ref)
1✔
505

506
                if focusItem = invalid
3✔
507
                    ' Case 3: ref might be a group HID or group node ID.
508
                    ' Try finding group by HID first, then by Node ID.
509
                    group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
1✔
510
                    if group <> invalid
3✔
511
                        ' If group found, find its default/entry focus item recursively.
512
                        HID = m.capturingFocus_recursively(group.HID)
1✔
513
                        focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
1✔
514

515
                        ' else: ref is not a known FocusItem HID or Group identifier
516
                    end if
517
                end if
518
            end if
519

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

540
            ' Found a valid focusItem to target
541
            HID = focusItem.HID
1✔
542

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

547
            ' Cannot focus an invisible item.
548
            if focusItem.node.visible = false and isFocused = true then return false
2✔
549

550
            ' Determine if native focus should be enabled (request or item default)
551
            enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
1✔
552

553
            ' Prevent focusing a disabled item.
554
            preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
1✔
555
            if preventFocusOnDisabled
2✔
556
                return false ' Indicate focus change failed
×
557
            end if
558

559
            ' Prepare ancestor groups for notification (from highest ancestor to closest parent)
560
            focusChainGroups = m.findAncestorGroups(focusItem.HID) ' Groups containing the new focus
1✔
561
            lastFocusChainingGroups = []
1✔
562

563
            ' Handle blurring the previously focused item
564
            if m.globalFocusHID <> "" ' If something was focused before
2✔
565
                lastFocused = m.focusItemStack.get(m.globalFocusHID)
1✔
566
                if lastFocused <> invalid ' Check if the last focused widget hasn't been destroyed
3✔
567
                    ' Record the last focused item within its parent group for potential future use (e.g., returning focus)
568
                    lastFocusChainingGroups = m.findAncestorGroups(m.globalFocusHID)
1✔
569
                    if lastFocusChainingGroups.Count() > 0
3✔
570
                        parentGroupHID = lastFocusChainingGroups[0]
1✔
571
                        if parentGroupHID <> invalid and parentGroupHID <> ""
3✔
572
                            group = m.groupStack.get(parentGroupHID)
1✔
573
                            if group <> invalid
3✔
574
                                group.setLastFocusedHID(m.globalFocusHID)
1✔
575
                            end if
576
                        end if
577
                    end if
578
                end if
579
            end if
580

581
            ' Prepare notification list: all affected groups (unique)
582
            allAffectedGroups = []
1✔
583
            for each groupHID in focusChainGroups
1✔
584
                allAffectedGroups.unshift(groupHID) ' Add in reverse order (highest ancestor first)
1✔
585
            end for
586
            for each groupHID in lastFocusChainingGroups
1✔
587
                if -1 = Rotor.Utils.findInArray(allAffectedGroups, groupHID)
2✔
588
                    allAffectedGroups.unshift(groupHID) ' Add in reverse order if not already present
1✔
589
                end if
590
            end for
591

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

595
            ' Blur the previously focused item (after notification)
596
            if m.globalFocusHID <> "" and lastFocused <> invalid
2✔
597
                lastFocused.applyFocus(false, enableNativeFocus)
1✔
598
            end if
599

600
            ' Apply focus state (focused/blurred) to the target item.
601
            focusItem.applyFocus(isFocused, enableNativeFocus)
1✔
602

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

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

614
            return true
1✔
615

616
        end function
617

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

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

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

634
                end for
635
            end if
636
        end sub
637

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

649
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
650
            focused = m.getFocusedItem()
1✔
651
            handled = focused.callLongPressHandler(isLongPress, key)
1✔
652
            if handled then return
2✔
653

654
            focusChainGroups = m.findAncestorGroups(focused.HID)
1✔
655
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
1✔
656
        end sub
657

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

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

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

672
            return ""
1✔
673
        end function
674

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

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

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

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

700
            return minDistItem.HID
1✔
701
        end function
702

703

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

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

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

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

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

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

742
            return newHID
1✔
743
        end function
744

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

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

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

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

786
            return ""
×
787
        end function
788

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

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

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

804
                ' Check group's direction configuration
805
                nodeId = group.getStaticNodeIdInDirection(direction)
1✔
806

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

824
                ancestorIndex++
1✔
825
            end while
826

827
            return newHID
1✔
828
        end function
829

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

845
        function executeNavigationAction(key as string, press as boolean) as object
846

847
            if true = press
3✔
848

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

857
                    newHID = ""
1✔
858
                    direction = key
1✔
859

860
                    ' (1) Pick up current focused item
861

862
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
863

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

871

872
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
873
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
874

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

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

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

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

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

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

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

920
                else if key = "OK"
×
921

922
                    return m.parseOnKeyEventResult(key, true, true)
×
923

924
                end if
925
            end if
926

927
            return m.parseOnKeyEventResult(key, false, false)
×
928

929
        end function
930

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

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

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

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

975
            refSegmentTop = focused.metrics.segments[Rotor.Const.Segment.TOP]
1✔
976
            refSegmentRight = focused.metrics.segments[Rotor.Const.Segment.RIGHT]
1✔
977
            refSegmentLeft = focused.metrics.segments[Rotor.Const.Segment.LEFT]
1✔
978
            refSegmentBottom = focused.metrics.segments[Rotor.Const.Segment.BOTTOM]
1✔
979
            referencePoint = { x: (refSegmentTop.x1 + refSegmentRight.x2) / 2, y: (refSegmentTop.y1 + refSegmentRight.y2) / 2 }
1✔
980

981
            validators = {
1✔
982

983
                "left": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
984
                    right = segments[Rotor.Const.Segment.RIGHT]
985
                    ' Candidate's right edge must be strictly left of focused element's left edge
986
                    return right.x2 <= refSegmentLeft.x1 ? { isValid: true, segment: right } : { isValid: false }
987
                end function,
988

989
                "up": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
990
                    bottom = segments[Rotor.Const.Segment.BOTTOM]
991
                    ' Candidate's bottom edge must be strictly above focused element's top edge
992
                    return bottom.y2 <= refSegmentTop.y1 ? { isValid: true, segment: bottom } : { isValid: false }
993
                end function,
994

995
                "right": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
996
                    left = segments[Rotor.Const.Segment.LEFT]
997
                    ' Candidate's left edge must be strictly right of focused element's right edge
998
                    return left.x1 >= refSegmentRight.x2 ? { isValid: true, segment: left } : { isValid: false }
999
                end function,
1000

1001
                "down": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
1002
                    top = segments[Rotor.Const.Segment.TOP]
1003
                    ' Candidate's top edge must be strictly below focused element's bottom edge
1004
                    return top.y1 >= refSegmentBottom.y2 ? { isValid: true, segment: top } : { isValid: false }
1005
                end function
1006
            }
1007
            segments = {}
1✔
1008
            validator = validators[direction]
1✔
1009
            for each HID in focusItemsHIDlist
1✔
1010
                if HID <> focused.HID
3✔
1011
                    focusItem = m.focusItemStack.get(HID)
1✔
1012
                    focusItem.refreshBounding()
1✔
1013
                    ' Pass appropriate reference segments based on direction
1014
                    if direction = "left" or direction = "right"
3✔
1015
                        result = validator(referencePoint, focusItem.metrics.segments, refSegmentLeft, refSegmentRight)
1✔
1016
                    else ' up or down
3✔
1017
                        result = validator(referencePoint, focusItem.metrics.segments, refSegmentTop, refSegmentBottom)
1✔
1018
                    end if
1019
                    if result.isValid
3✔
1020
                        segments[HID] = result.segment
1✔
1021
                    end if
1022
                end if
1023
            end for
1024

1025
            return segments
1✔
1026
        end function
1027

1028
        sub destroy()
1029
            ' Remove all groups
1030
            for each HID in m.groupStack.getAll()
1✔
1031
                m.groupStack.remove(HID)
1✔
1032
            end for
1033
            ' Remove all focus items
1034
            for each HID in m.focusItemStack.getAll()
1✔
1035
                m.focusItemStack.remove(HID)
1✔
1036
            end for
1037
            m.longPressTimer.unobserveFieldScoped("fire")
1✔
1038
            m.longPressTimer = invalid
1✔
1039
            m.widgetTree = invalid
1✔
1040
        end sub
1041

1042
    end class
1043

1044
    namespace FocusPluginHelper
1045

1046
        class BaseEntryStack extends Rotor.BaseStack
1047

1048
            function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
1049
                if ancestorHID <> "0"
3✔
1050
                    filteredStack = {}
1✔
1051
                    for each HID in m.stack
1✔
1052
                        if Rotor.Utils.isDescendantHID(HID, ancestorHID)
2✔
1053
                            filteredStack[HID] = m.get(HID)
1✔
1054
                        end if
1055
                    end for
1056
                else
3✔
1057
                    filteredStack = m.stack
1✔
1058
                end if
1059
                HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
1✔
1060
                return HID <> "" ? m.get(HID) : invalid
1✔
1061
            end function
1062

1063
            override sub remove(HID as string)
1064
                item = m.get(HID)
1✔
1065
                item.destroy()
1✔
1066
                super.remove(HID)
1✔
1067
            end sub
1068

1069
        end class
1070

1071
        class GroupStack extends BaseEntryStack
1072

1073
            function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1074
                foundHID = ""
×
1075
                for each HID in possibleGroups
×
1076
                    group = m.get(HID)
×
1077
                    if group.id = nodeId
×
1078
                        foundHID = group.HID
×
1079
                        exit for
1080
                    end if
1081
                end for
1082
                return foundHID
×
1083
            end function
1084

1085
        end class
1086

1087

1088
        class FocusItemStack extends BaseEntryStack
1089

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

1102
            function hasEnabled(HID as string) as boolean
1103
                if m.has(HID)
×
1104
                    focusItem = m.get(HID)
×
1105
                    return focusItem.isEnabled
×
1106
                else
×
1107
                    return false
×
1108
                end if
1109
            end function
1110

1111
        end class
1112

1113
        class BaseFocusConfig
1114

1115
            autoSetIsFocusedOnContext as boolean
1116
            staticDirection as object
1117

1118
            sub new (config as object)
1119

1120
                m.HID = config.HID
1✔
1121
                m.id = config.id
1✔
1122

1123
                m.widget = config.widget
1✔
1124
                m.node = m.widget.node
1✔
1125
                m.isFocused = config.isFocused ?? false
1✔
1126

1127
                m.autoSetIsFocusedOnContext = config.autoSetIsFocusedOnContext ?? true
1✔
1128

1129
                m.isEnabled = config.isEnabled ?? true
1✔
1130
                m.staticDirection = {}
1✔
1131
                m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1✔
1132
                m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
1✔
1133
                m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
1✔
1134
                m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
1✔
1135
                m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
1✔
1136

1137
                m.onFocusChanged = config.onFocusChanged
1✔
1138
                m.longPressHandler = config.longPressHandler
1✔
1139
                m.onFocus = config.onFocus
1✔
1140

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

1143
                ' convenience (usually this is used on viewModelState)
1144
                if false = m.widget.viewModelState.DoesExist("isFocused") and true = m.autoSetIsFocusedOnContext
2✔
1145
                    m.widget.viewModelState.isFocused = false ' as default
1✔
1146
                end if
1147

1148
            end sub
1149

1150

1151
            HID as string
1152
            id as string
1153
            idByKeys as object
1154
            isEnabled as boolean
1155
            isFocused as boolean
1156
            onFocusChanged as dynamic
1157
            onFocus as dynamic
1158
            longPressHandler as dynamic
1159
            node as object
1160
            widget as object
1161

1162
            function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1163
                direction = m.staticDirection[direction]
1✔
1164
                if Rotor.Utils.isFunction(direction)
2✔
1165
                    return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
×
1166
                else
3✔
1167
                    return direction ?? ""
1✔
1168
                end if
1169
            end function
1170

1171
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1172
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1173
                if true = isFocused
3✔
1174
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1175
                end if
1176
            end sub
1177

1178
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1179
                if Rotor.Utils.isFunction(m.longPressHandler)
2✔
1180
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1181
                else
3✔
1182
                    return false
1✔
1183
                end if
1184
            end function
1185

1186
            sub destroy()
1187
                m.widget = invalid
1✔
1188
                m.node = invalid
1✔
1189
                m.onFocusChanged = invalid
1✔
1190
                m.longPressHandler = invalid
1✔
1191
            end sub
1192

1193
        end class
1194

1195
        class GroupClass extends BaseFocusConfig
1196
            ' Note: Spatial navigation is supported within group, there is no spatial navigation between groups
1197
            ' If you want to focus out to another group, you need to config a direction prop.
1198
            ' You can set a groupId or any focusItem widgetId.
1199
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1200
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1201

1202
            sub new (config as object)
1203
                super(config)
1✔
1204
                m.defaultFocusId = config.defaultFocusId ?? ""
1✔
1205
                m.lastFocusedHID = config.lastFocusedHID ?? ""
1✔
1206
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
1✔
1207
            end sub
1208

1209
            defaultFocusId as string
1210
            lastFocusedHID as string
1211
            enableSpatialEnter as boolean
1212
            focusItemsRef as object
1213
            groupsRef as object
1214

1215
            isFocusItem = false
1216
            isGroup = true
1217

1218
            sub setLastFocusedHID(lastFocusedHID as string)
1219
                m.lastFocusedHID = lastFocusedHID
1✔
1220
            end sub
1221

1222
            function getGroupMembersHIDs()
1223
                ' Collect all focusItems that are descendants of this group
1224
                ' Exclude items that belong to nested sub-groups
1225
                focusItems = m.focusItemsRef.getAll()
1✔
1226
                groups = m.groupsRef.getAll()
1✔
1227
                HIDlen = Len(m.HID)
1✔
1228
                collection = []
1✔
1229
                groupsKeys = groups.keys()
1✔
1230
                groupsCount = groups.Count()
1✔
1231

1232
                for each focusItemHID in focusItems
1✔
1233
                    ' Check if focusItem is a descendant of this group
1234
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
1✔
1235
                    if isDescendant
2✔
1236
                        ' Check if focusItem belongs to a nested sub-group
1237
                        shouldExclude = false
1✔
1238
                        otherGroupIndex = 0
1✔
1239
                        while shouldExclude = false and otherGroupIndex < groupsCount
1✔
1240
                            otherGroupHID = groupsKeys[otherGroupIndex]
1✔
1241
                            otherGroupHIDlen = Len(otherGroupHID)
1✔
1242
                            ' Exclude if belongs to deeper nested group
1243
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
1✔
1244
                            otherGroupIndex++
1✔
1245
                        end while
1246

1247
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1248
                    end if
1249
                end for
1250

1251
                return collection
1✔
1252
            end function
1253

1254
            '
1255
            ' getFallbackNodeId - Returns the nodeId to use for fallback (defaultFocusId or lastFocusedHID)
1256
            '
1257
            ' @returns {string} The nodeId to use for fallback, or empty string if none
1258
            '
1259
            function getFallbackNodeId() as string
1260
                if m.lastFocusedHID <> ""
2✔
1261
                    ' Note: lastFocusedHID is already a HID, not a nodeId, so we need to get the nodeId
1262
                    lastFocusedItem = m.focusItemsRef.get(m.lastFocusedHID)
×
1263
                    if lastFocusedItem <> invalid
×
1264
                        return lastFocusedItem.id
×
1265
                    end if
1266
                end if
1267

1268
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1269
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1270
                else
3✔
1271
                    return m.defaultFocusId
1✔
1272
                end if
1273
            end function
1274

1275
            function getFallbackIdentifier() as string
1276
                HID = ""
1✔
1277
                if m.lastFocusedHID <> ""
2✔
1278
                    return m.lastFocusedHID
×
1279
                else
3✔
1280
                    if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1281
                        defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1282
                    else
3✔
1283
                        defaultFocusId = m.defaultFocusId
1✔
1284
                    end if
1285

1286
                    if defaultFocusId <> ""
3✔
1287
                        focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1288
                        if focusItemsHIDlist.Count() > 0
3✔
1289

1290
                            ' Try find valid HID in focusItems by node id
1291
                            focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1✔
1292
                            if focusItemHID <> ""
3✔
1293
                                HID = focusItemHID
1✔
1294
                            end if
1295

1296
                        else
1297

3✔
1298
                            return defaultFocusId
1✔
1299

1300
                        end if
1301
                    end if
1302

1303
                end if
1304

1305
                return HID
1✔
1306
            end function
1307

1308
            function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1309
                HID = ""
1✔
1310
                for each HID in focusItemsHIDlist
1✔
1311
                    focusItem = m.focusItemsRef.get(HID)
1✔
1312
                    if focusItem <> invalid and focusItem.id = nodeId
3✔
1313
                        HID = focusItem.HID
1✔
1314
                        exit for
1315
                    end if
1316
                end for
1317
                return HID
1✔
1318
            end function
1319

1320
            sub applyFocus(isFocused as boolean)
1321
                if m.isFocused = isFocused then return
2✔
1322

1323
                m.isFocused = isFocused
1✔
1324

1325
                if m.autoSetIsFocusedOnContext
3✔
1326
                    m.widget.viewModelState.isInFocusChain = isFocused
1✔
1327
                end if
1328
                m.node.setField("isFocused", isFocused)
1✔
1329
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1330
            end sub
1331

1332
            override sub destroy()
1333
                super.destroy()
1✔
1334
                m.focusItemsRef = invalid
1✔
1335
                m.groupsRef = invalid
1✔
1336
            end sub
1337

1338

1339

1340
        end class
1341

1342
        class FocusItemClass extends BaseFocusConfig
1343

1344
            sub new (config as object)
1345
                super(config)
1✔
1346

1347
                m.onSelected = config.onSelected ?? ""
1✔
1348
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
1✔
1349
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1350
            end sub
1351

1352
            ' You can set a groupId or any focusItem widgetId.
1353
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1354
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1355

1356
            ' key as string
1357
            isFocusItem = true
1358
            isGroup = false
1359
            enableNativeFocus as boolean
1360
            enableSpatialNavigation as boolean
1361
            onSelected as dynamic
1362

1363
            private metrics = {
1364
                segments: {}
1365
            }
1366
            private bounding as object
1367

1368

1369
            sub refreshBounding()
1370
                b = m.node.sceneBoundingRect()
1✔
1371
                rotation = m.node.rotation
1✔
1372

1373
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1374
                ' That is why we can use translation without knowing the value of inheritParentTransform
1375
                ' If bounding x or y are not zero, then bounding will include the node's translation
1376
                if rotation = 0
3✔
1377
                    if b.y = 0 and b.x = 0
2✔
1378
                        t = m.node.translation
×
1379
                        b.x += t[0]
×
1380
                        b.y += t[1]
×
1381
                    end if
1382

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

1413
                    ' Calculate rotated segments
1414
                    segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
×
1415
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1416
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
×
1417

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

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

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

1430
                    ' Calculate rotated middle point
1431
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1432
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1433
                    m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
×
1434

1435
                end if
1436
            end sub
1437

1438
            override sub destroy()
1439
                m.onSelected = invalid
1✔
1440
                m.metrics.segments.Clear()
1✔
1441
                super.destroy()
1✔
1442
            end sub
1443

1444
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1445
                if m.isFocused = isFocused then return
2✔
1446

1447
                m.isFocused = isFocused
1✔
1448

1449
                if m.autoSetIsFocusedOnContext
3✔
1450
                    m.widget.viewModelState.isFocused = isFocused
1✔
1451
                end if
1452

1453
                m.node.setField("isFocused", isFocused)
1✔
1454

1455
                if enableNativeFocus or m.enableNativeFocus
1456
                    m.node.setFocus(isFocused)
×
1457
                end if
1458

1459
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1460

1461
            end sub
1462

1463
            sub callOnSelectedFnOnWidget()
1464
                Rotor.Utils.callbackScoped(m.onSelected, m.widget)
×
1465
            end sub
1466

1467
        end class
1468

1469
        class ClosestSegmentToPointCalculatorClass
1470

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

1474
                A = x - x1
1✔
1475
                B = y - y1
1✔
1476
                C = x2 - x1
1✔
1477
                D = y2 - y1
1✔
1478

1479
                dot = A * C + B * D
1✔
1480
                len_sq = C * C + D * D
1✔
1481
                param = -1
1✔
1482
                if len_sq <> 0
3✔
1483
                    param = dot / len_sq
1✔
1484
                end if
1485

1486
                xx = 0
1✔
1487
                yy = 0
1✔
1488

1489
                if param < 0
2✔
1490
                    xx = x1
×
1491
                    yy = y1
×
1492
                else if param > 1
2✔
1493
                    xx = x2
×
1494
                    yy = y2
×
1495
                else
3✔
1496
                    xx = x1 + param * C
1✔
1497
                    yy = y1 + param * D
1✔
1498
                end if
1499

1500
                dx = x - xx
1✔
1501
                dy = y - yy
1✔
1502
                return dx * dx + dy * dy
1✔
1503
            end function
1504

1505
            function distToSegment(p as object, s1 as object, s2 as object)
1506
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1507
            end function
1508

1509
        end class
1510

1511
    end namespace
1512

1513
    namespace FocusPluginHelper
1514

1515
        sub longPressObserverCallback(msg)
1516
            extraInfo = msg.GetInfo()
1✔
1517

1518
            pluginKey = extraInfo["pluginKey"]
1✔
1519

1520
            globalScope = GetGlobalAA()
1✔
1521
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
1✔
1522
            plugin = frameworkInstance.plugins[pluginKey]
1✔
1523
            plugin.isLongPress = true
1✔
1524
            ' plugin.longPressStartHID = plugin.globalFocusHID
1525
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
1✔
1526

1527
        end sub
1528

1529
    end namespace
1530

1531
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