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

mobalazs / rotor-framework / 18821839231

26 Oct 2025 05:54PM UTC coverage: 80.878% (+20.0%) from 60.868%
18821839231

push

github

mobalazs
temporary fix for test (Code instrumentation should preserve the original constructor call sequence and semantics.)

1713 of 2118 relevant lines covered (80.88%)

1.09 hits per line

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

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

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

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

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

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

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

218
        ' Methods injected into widgets managed by this plugin
219
        widgetMethods = {
220

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

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

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

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

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

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

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

298
            '''''''''
299
            ' triggerKeyPress: Simulate key press
300
            '
301
            ' @param {string} key pressed key
302
            ' @return {object} The widget instance that currently holds focus, or invalid.
303
            '''''''''
304
            triggerKeyPress: function(key) as object
305
                globalScope = GetGlobalAA()
1✔
306
                pluginKey = m.pluginKey
1✔
307
                return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].onKeyEventHandler(key, true)
1✔
308
            end function
309

310
        }
311

312

313

314

315
        longPressDuration = 0.4 ' Default seconds for long press detection
316
        globalFocusHID = "" ' HID of the currently focused item
317
        globalFocusId = "" ' ID of the currently focused item
318
        isLongPress = false ' Flag indicating if a long press is active
319
        longPressKey = "" ' Key being held for long press
320
        ' m.longPressStartHID = "" ' Key being held for long press
321
        measurementCheckHIDs = [] ' Potentially for deferred measurement checks (unused currently)
322
        enableLongPressFeature = true ' Global flag for enabling/disabling navigation
323
        enableFocusNavigation = true ' Global flag for enabling/disabling navigation
324
        widgetTree as object ' Reference to the main widget tree
325
        focusItemStack = new Rotor.FocusPluginHelper.FocusItemStack() ' Stores FocusItem instances
326
        groupStack = new Rotor.FocusPluginHelper.GroupStack() ' Stores Group instances
327
        distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass() ' Helper for spatial navigation
328
        longPressTimer = CreateObject("roSGNode", "Timer") ' Timer for long press detection
329
        frameworkInstance as Rotor.Framework ' injected by framework
330

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

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

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

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

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

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

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

394
            ' Ensure essential identifiers are in the config
395
            config.id = id
1✔
396
            config.HID = widget.HID
1✔
397
            ' config.HID = widget.HID ' Duplicate line removed
398

399
            ' Handle group configuration if present
400
            if isGroup
2✔
401
                m.setupGroup(HID, config, widget)
1✔
402
            else
403
                ' Handle focus item configuration if applicable
404
                ' if isFocusItem
3✔
405
                m.setupFocusItem(HID, config, widget)
1✔
406
            end if
407
        end sub
408

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

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

440
            ' Create and register the FocusItem instance
441
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
1✔
442
            m.focusItemStack.set(HID, newFocusItem)
1✔
443

444
            ' m.measurementCheckHIDs.push(HID) ' Potential future use for measurement checks
445
        end sub
446

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

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

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

497
            ' Resolve reference (HID or ID) to a focusItem item.
498
            focusItem = invalid ' Initialize target focus item
1✔
499

500
            ' Exit if reference is empty or invalid.
501
            if ref = invalid or ref = "" then return false
2✔
502

503
            if m.focusItemStack.has(ref)
2✔
504
                ' Case 1: ref is a valid focusItem HID.
505
                focusItem = m.focusItemStack.get(ref)
1✔
506
            else
507
                ' Case 2: ref might be a focusItem node ID.
3✔
508
                focusItem = m.focusItemStack.getByNodeId(ref)
1✔
509

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

519
                        ' else: ref is not a known FocusItem HID or Group identifier
520
                    end if
521
                end if
522
            end if
523

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

544
            ' #if debug
545
            ' Rotor.Utils.BasicLogger(`[PLUGIN][FOCUS] Resolved focus request for ref "${ref}" to FocusItem ID: ${focusItem.id} (HID: ${focusItem.HID})`)
546
            ' #end if
547

548
            ' Found a valid focusItem to target
549
            HID = focusItem.HID
1✔
550

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

555
            ' Cannot focus an invisible item.
556
            ' TODO: future improvement: make this condition optional and also make also for opacity = 0
557
            if focusItem.node.visible = false and isFocused = true then return false
2✔
558

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

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

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

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

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

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

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

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

610
            return true
1✔
611

612
        end function
613

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

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

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

630
                end for
631
            end if
632
        end sub
633

634
        sub notifyLongPressAtAncestorGroups(isLongPress as boolean, key as string, HID as string, groupHIDs = [] as object)
635

636
            ' Notify all ancestor groups
637
            if groupHIDs.Count() > 0
×
638
                for each groupHID in groupHIDs
×
639

640
                    group = m.groupStack.get(groupHID)
×
641
                    handled = group.callLongPressHandler(isLongPress, key)
×
642
                    if handled then exit for
×
643

644
                end for
645
            end if
646
        end sub
647

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

653
            focusChainGroups = m.findAncestorGroups(focused.HID)
×
654
            ' focusChainGroups = m.findAncestorGroups(m.longPressStartHID) ' alternative approach; future improvement to make it optional
655
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
×
656
        end sub
657

658
        function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object) as string
659

660
            if focused.enableSpatialNavigation = false then return ""
2✔
661
            if direction = Rotor.Const.Direction.BACK then return ""
2✔
662

663
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
1✔
664
            if index >= 0 then focusItemsHIDlist.delete(index)
1✔
665
            segments = m.collectSegments(focused, direction, focusItemsHIDlist)
1✔
666
            if segments.Count() > 0
3✔
667
                return m.findClosestSegment(segments, focused.metrics.middlePoint)
1✔
668
            else
3✔
669
                return ""
1✔
670
            end if
671
        end function
672

673
        function findClosestSegment(segments as object, middlePoint as object) as string
674
            distances = []
1✔
675
            for each HID in segments
1✔
676
                segment = segments[HID]
1✔
677
                distance = m.distanceCalculator.distToSegment(middlePoint, {
1✔
678
                    x: segment.x1,
679
                    y: segment.y1
680
                }, {
681
                    x: segment.x2,
682
                    y: segment.y2
683
                })
684

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

691
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance",
1✔
692
            function(a, b) as dynamic
693
                return a < b
694
            end function
695
            )
696

697
            return minDistItem.HID
1✔
698
        end function
699

700

701
        ' Technically this is a waterfall of fallback's of groups (linked together with defaultFocusId).
702
        function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
703

704
            ' Next group
705
            group = m.groupStack.get(identifier) ' if identifier is a HID
1✔
706
            if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
1✔
707
            if group = invalid then return ""
2✔
708

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

712
            if m.focusItemStack.has(newHID)
3✔
713
                ' FOUND,
714
                ' but check "spatial enter to group" feature ...
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
                ' FOUND at least
721

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

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

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

739
            return newHID
1✔
740

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
        ' @return {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
            ancestorGroups = m.findAncestorGroups(groupHID)
1✔
791
            ancestorGroups.unshift(groupHID)
1✔
792
            ancestorGroupsCount = ancestorGroups.Count()
1✔
793
            ancestorIndex = 0
1✔
794
            ' Note that
795
            ' # parent group is at zero index
796
            ' # if HID is a focusItem then the closest group is included
797
            ' # if HID is a group then itself is not included
798

799
            while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
1✔
800

801
                ' next group
802
                groupHID = ancestorGroups[ancestorIndex]
1✔
803
                group = m.groupStack.get(groupHID)
1✔
804

805
                ' Check if the current group is pointing to another group, which has a valid focusItem member - defined by defaultFocusId
806
                nodeId = group.getStaticNodeIdInDirection(direction) ' Note that this is a nodeId
1✔
807

808
                if Rotor.Utils.isBoolean(nodeId)
2✔
809
                    ' It means that focus is handled, and no need further action by plugin.
810
                    if nodeId = true
3✔
811
                        newHID = true ' Note that type changed, so loop will exit
1✔
812
                    else
×
813
                        newHID = ""
×
814
                    end if
815
                else
816

3✔
817
                    if nodeId <> ""
3✔
818
                        otherGroup = m.groupStack.getByNodeId(nodeId)
1✔
819
                        if otherGroup <> invalid
3✔
820
                            newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
1✔
821
                        end if
822
                    end if
823

824
                end if
825

826
                ancestorIndex++
1✔
827
            end while
828

829
            return newHID
1✔
830
        end function
831

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

847
        function executeNavigationAction(key as string, press as boolean) as object
848

849
            if true = press
3✔
850

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

859
                    newHID = ""
1✔
860
                    direction = key
1✔
861

862
                    ' (1) Pick up current focused item
863

864
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
865

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

873

874
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
875
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
876

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

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

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

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

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

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

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

922
                else if key = "OK"
×
923

924
                    return m.parseOnKeyEventResult(key, true, true)
×
925

926
                end if
927
            end if
928

929
            return m.parseOnKeyEventResult(key, false, false)
×
930

931
        end function
932

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

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

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

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

977
            refSegmentTop = focused.metrics.segments[Rotor.Const.Segment.TOP]
1✔
978
            refSegmentRight = focused.metrics.segments[Rotor.Const.Segment.RIGHT]
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) as object
984
                    right = segments[Rotor.Const.Segment.RIGHT]
985
                    ' stop
986
                    return right.x1 <= referencePoint.x ? { isValid: true, segment: right } : { isValid: false }
987
                end function,
988

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

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

1001
                "down": function(referencePoint as object, segments as object) as object
1002
                    top = segments[Rotor.Const.Segment.TOP]
1003
                    ' stop
1004
                    return top.y1 >= referencePoint.y ? { 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
                    result = validator(referencePoint, focusItem.metrics.segments)
1✔
1014
                    if result.isValid
3✔
1015
                        segments[HID] = result.segment
1✔
1016
                    end if
1017
                end if
1018
            end for
1019

1020
            return segments
1✔
1021
        end function
1022

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

1037
    end class
1038

1039
    namespace FocusPluginHelper
1040

1041
        class BaseEntryStack extends Rotor.BaseStack
1042

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

1058
            override sub remove(HID as string)
1059
                item = m.get(HID)
1✔
1060
                item.destroy()
1✔
1061
                super.remove(HID)
1✔
1062
            end sub
1063

1064
        end class
1065

1066
        class GroupStack extends BaseEntryStack
1067

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

1080
        end class
1081

1082

1083
        class FocusItemStack extends BaseEntryStack
1084

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

1097
            function hasEnabled(HID as string) as boolean
1098
                if m.has(HID)
×
1099
                    focusItem = m.get(HID)
×
1100
                    return focusItem.isEnabled
×
1101
                else
×
1102
                    return false
×
1103
                end if
1104
            end function
1105

1106
        end class
1107

1108
        class BaseFocusConfig
1109

1110
            autoSetIsFocusedOnContext as boolean
1111
            staticDirection as object
1112

1113
            sub new (config as object)
1114

1115
                m.HID = config.HID
1✔
1116
                m.id = config.id
1✔
1117

1118
                m.widget = config.widget
1✔
1119
                m.node = m.widget.node
1✔
1120
                m.isFocused = config.isFocused ?? false
1✔
1121

1122
                m.autoSetIsFocusedOnContext = config.autoSetIsFocusedOnContext ?? true
1✔
1123

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

1132
                m.onFocusChanged = config.onFocusChanged
1✔
1133
                m.longPressHandler = config.longPressHandler
1✔
1134
                m.onFocus = config.onFocus
1✔
1135

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

1138
                ' convenience (usually this is used on viewModelState)
1139
                if false = m.widget.viewModelState.DoesExist("isFocused") and true = m.autoSetIsFocusedOnContext
2✔
1140
                    m.widget.viewModelState.isFocused = false ' as default
1✔
1141
                end if
1142

1143
            end sub
1144

1145

1146
            HID as string
1147
            id as string
1148
            idByKeys as object
1149
            isEnabled as boolean
1150
            isFocused as boolean
1151
            onFocusChanged as dynamic
1152
            onFocus as dynamic
1153
            longPressHandler as dynamic
1154
            node as object
1155
            widget as object
1156

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

1166
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1167
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1168
                if true = isFocused
3✔
1169
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1170
                end if
1171
            end sub
1172

1173
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1174
                if Rotor.Utils.isFunction(m.longPressHandler)
×
1175
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1176
                else
×
1177
                    return false
×
1178
                end if
1179
            end function
1180

1181
            sub destroy()
1182
                m.widget = invalid
1✔
1183
                m.node = invalid
1✔
1184
                m.onFocusChanged = invalid
1✔
1185
                m.longPressHandler = invalid
1✔
1186
            end sub
1187

1188
        end class
1189

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

1197
            sub new (config as object)
1198
                super(config)
1✔
1199
                m.defaultFocusId = config.defaultFocusId ?? ""
1✔
1200
                m.lastFocusedHID = config.lastFocusedHID ?? ""
1✔
1201
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
1✔
1202
            end sub
1203

1204
            defaultFocusId as string
1205
            lastFocusedHID as string
1206
            enableSpatialEnter as boolean
1207
            focusItemsRef as object
1208
            groupsRef as object
1209

1210
            isFocusItem = false
1211
            isGroup = true
1212

1213
            sub setLastFocusedHID(lastFocusedHID as string)
1214
                m.lastFocusedHID = lastFocusedHID
1✔
1215
            end sub
1216

1217
            function getGroupMembersHIDs()
1218
                focusItems = m.focusItemsRef.getAll()
1✔
1219
                groups = m.groupsRef.getAll()
1✔
1220
                HIDlen = Len(m.HID)
1✔
1221
                collection = []
1✔
1222
                groupsKeys = groups.keys()
1✔
1223
                groupsCount = groups.Count()
1✔
1224
                for each focusItemHID in focusItems
1✔
1225
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
1✔
1226
                    if true = isDescendant
2✔
1227
                        shouldExclude = false
1✔
1228
                        otherGroupIndex = 0
1✔
1229
                        while shouldExclude = false and otherGroupIndex < groupsCount
1✔
1230
                            otherGroupHID = groupsKeys[otherGroupIndex]
1✔
1231
                            otherGroupHIDlen = Len(otherGroupHID)
1✔
1232
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
1✔
1233
                            otherGroupIndex++
1✔
1234
                        end while
1235
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1236
                    end if
1237
                end for
1238
                return collection
1✔
1239
                ' Collect all focusItems which contain the group HID
1240
                ' Exclude any other Group HID's
1241
            end function
1242

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

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

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

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

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

1285
                        else
1286

3✔
1287
                            return defaultFocusId
1✔
1288

1289
                        end if
1290
                    end if
1291

1292
                end if
1293

1294
                return HID
1✔
1295
            end function
1296

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

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

1312
                m.isFocused = isFocused
1✔
1313

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

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

1327

1328

1329
        end class
1330

1331
        class FocusItemClass extends BaseFocusConfig
1332

1333
            sub new (config as object)
1334
                super(config)
1✔
1335

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

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

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

1352
            private metrics = {
1353
                segments: {}
1354
            }
1355
            private bounding as object
1356

1357
            ' override sub update(newConfig as object)
1358
            ' super.update(newConfig)
1359
            ' end sub
1360

1361
            sub refreshBounding()
1362
                b = m.node.sceneBoundingRect()
1✔
1363
                rotation = m.node.rotation
1✔
1364
                ' if m.node.id = "option-blueTheme" then stop
1365
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1366
                ' That is way we can use translation without knowing the value of inheritParentTransform
1367
                ' If bounding x or y are not zero, then bounding will include the node's translation
1368
                if rotation = 0
3✔
1369
                    if b.y = 0 and b.x = 0
2✔
1370
                        t = m.node.translation
×
1371
                        b.x += t[0]
×
1372
                        b.y += t[1]
×
1373
                    end if
1374
                    b.height = 40
1✔
1375
                    b.width = 150
1✔
1376
                    m.metrics.append(b) ' shallow copy
1✔
1377
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
1✔
1378
                        x1: b.x, y1: b.y,
1379
                        x2: b.x, y2: b.y + b.height
1380
                    }
1381
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
1✔
1382
                        x1: b.x, y1: b.y,
1383
                        x2: b.x + b.width, y2: b.y
1384
                    }
1385
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
1✔
1386
                        x1: b.x + b.width, y1: b.y,
1387
                        x2: b.x + b.width, y2: b.y + b.height
1388
                    }
1389
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
1✔
1390
                        x1: b.x, y1: b.y + b.height,
1391
                        x2: b.x + b.width, y2: b.y + b.height
1392
                    }
1393
                    m.metrics.middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
1✔
1394
                else
×
1395
                    scaleRotateCenter = m.node.scaleRotateCenter
×
1396
                    dims = m.node.localBoundingRect() ' We need this to get proper (rotated value of rotated x and y)
×
1397
                    if b.y = 0 and b.x = 0
×
1398
                        t = m.node.translation
×
1399
                        b.x += t[0]
×
1400
                        b.y += t[1]
×
1401
                    end if
1402
                    b.width = dims.width
×
1403
                    b.height = dims.height
×
1404
                    m.metrics.append(b)
×
1405

1406
                    segmentLEFT = {
×
1407
                        x1: b.x, y1: b.y,
1408
                        x2: b.x, y2: b.y + b.height
1409
                    }
1410
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1411
                    segmentLEFT = rotatedSegment
×
1412
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = segmentLEFT
×
1413

1414

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

1423

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

1432

1433
                    segmentBOTTOM = {
×
1434
                        x1: b.x, y1: b.y + b.height,
1435
                        x2: b.x + b.width, y2: b.y + b.height
1436
                    }
1437
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentBOTTOM.x1, segmentBOTTOM.y1, segmentBOTTOM.x2, segmentBOTTOM.y2, rotation, scaleRotateCenter)
×
1438
                    segmentBOTTOM = rotatedSegment
×
1439
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = segmentBOTTOM
×
1440

1441

1442
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1443
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1444
                    m.metrics.middlePoint = {
×
1445
                        y: rotatedMiddlePoint.y1,
1446
                        x: rotatedMiddlePoint.x1
1447
                    }
1448

1449
                end if
1450
            end sub
1451

1452
            override sub destroy()
1453
                m.onSelected = invalid
1✔
1454
                m.metrics.segments.Clear()
1✔
1455
                super.destroy()
1✔
1456
            end sub
1457

1458
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1459
                if m.isFocused = isFocused then return
2✔
1460

1461
                m.isFocused = isFocused
1✔
1462

1463
                if m.autoSetIsFocusedOnContext
3✔
1464
                    m.widget.viewModelState.isFocused = isFocused
1✔
1465
                end if
1466

1467
                m.node.setField("isFocused", isFocused)
1✔
1468

1469
                if enableNativeFocus or m.enableNativeFocus
2✔
1470
                    m.node.setFocus(isFocused)
×
1471
                end if
1472

1473
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1474

1475
            end sub
1476

1477
            sub callOnSelectedFnOnWidget()
1478
                Rotor.Utils.callbackScoped(m.onSelected, m.widget)
×
1479
            end sub
1480

1481
        end class
1482

1483
        class ClosestSegmentToPointCalculatorClass
1484

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

1488
                A = x - x1
1✔
1489
                B = y - y1
1✔
1490
                C = x2 - x1
1✔
1491
                D = y2 - y1
1✔
1492

1493
                dot = A * C + B * D
1✔
1494
                len_sq = C * C + D * D
1✔
1495
                param = -1
1✔
1496
                if len_sq <> 0
3✔
1497
                    param = dot / len_sq
1✔
1498
                end if
1499

1500
                xx = 0
1✔
1501
                yy = 0
1✔
1502

1503
                if param < 0
2✔
1504
                    xx = x1
×
1505
                    yy = y1
×
1506
                else if param > 1
2✔
1507
                    xx = x2
×
1508
                    yy = y2
×
1509
                else
3✔
1510
                    xx = x1 + param * C
1✔
1511
                    yy = y1 + param * D
1✔
1512
                end if
1513

1514
                dx = x - xx
1✔
1515
                dy = y - yy
1✔
1516
                return dx * dx + dy * dy
1✔
1517
            end function
1518

1519
            function distToSegment(p as object, s1 as object, s2 as object)
1520
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1521
            end function
1522

1523
        end class
1524

1525
    end namespace
1526

1527
    namespace FocusPluginHelper
1528

1529
        sub longPressObserverCallback(msg)
1530
            extraInfo = msg.GetInfo()
×
1531

1532
            pluginKey = extraInfo["pluginKey"]
×
1533

1534
            globalScope = GetGlobalAA()
×
1535
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
×
1536
            plugin = frameworkInstance.plugins[pluginKey]
×
1537
            plugin.isLongPress = true
×
1538
            ' plugin.longPressStartHID = plugin.globalFocusHID
1539
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
×
1540

1541
        end sub
1542

1543
    end namespace
1544

1545
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