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

mobalazs / rotor-framework / 22205737759

20 Feb 2026 12:15AM UTC coverage: 89.705% (-0.06%) from 89.769%
22205737759

push

github

mobalazs
refactor: update registerSourceObject and unregisterSourceObject methods for improved clarity and functionality

39 of 42 new or added lines in 3 files covered. (92.86%)

1 existing line in 1 file now uncovered.

2187 of 2438 relevant lines covered (89.7%)

1.25 hits per line

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

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

4
namespace Rotor
5

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

169
    const PRIMARY_FOCUS_PLUGIN_KEY = "focus"
170
    const GROUP_FOCUS_PLUGIN_KEY = "focusGroup"
171
    class FocusPlugin extends Rotor.BasePlugin
172

173
        pluginKey = PRIMARY_FOCUS_PLUGIN_KEY
174
        aliasPluginKey = GROUP_FOCUS_PLUGIN_KEY
175

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

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

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

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

216
                ' Determine whether this widget is a focus item or focus group
217
                targetKey = PRIMARY_FOCUS_PLUGIN_KEY
1✔
218
                if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY) and widget[PRIMARY_FOCUS_PLUGIN_KEY] <> invalid
3✔
219
                    targetKey = PRIMARY_FOCUS_PLUGIN_KEY
1✔
220
                else
3✔
221
                    targetKey = GROUP_FOCUS_PLUGIN_KEY
1✔
222
                end if
223

224
                ' Ensure target config exists
225
                if not Rotor.Utils.isAssociativeArray(widget[targetKey])
2✔
226
                    widget[targetKey] = {}
×
227
                end if
228

229
                ' Merge new config into existing widget config (or replace if non-AA)
230
                if Rotor.Utils.isAssociativeArray(newValue)
3✔
231
                    Rotor.Utils.deepExtendAA(widget[targetKey], newValue)
1✔
232
                else
×
233
                    widget[targetKey] = newValue
×
234
                end if
235

236
                scope.setFocusConfig(widget, widget[targetKey])
1✔
237
            end sub,
238

239
            ' ---------------------------------------------------------------------
240
            ' beforeDestroy - Hook executed before a widget is destroyed
241
            '
242
            ' Removes focus config. Clears global focus if this widget had it.
243
            '
244
            ' @param {object} scope - The plugin scope (this instance)
245
            ' @param {object} widget - The widget being destroyed
246
            '
247
            beforeDestroy: sub(scope as object, widget as object)
248
                hadFocus = scope.globalFocusHID = widget.HID
1✔
249
                scope.removeFocusConfig(widget.HID)
1✔
250
                if hadFocus
2✔
251
                    scope.storeGlobalFocusHID("", "")
1✔
252
                end if
253
            end sub
254
        }
255

256
        ' Widget methods - Injected into widgets managed by this plugin
257
        widgetMethods = {
258

259
            ' ---------------------------------------------------------------------
260
            ' enableFocusNavigation - Enables or disables focus navigation globally for this plugin
261
            '
262
            ' @param {boolean} enableFocusNavigation - True to enable, false to disable (default: true)
263
            '
264
            enableFocusNavigation: sub(enableFocusNavigation = true as boolean)
265
                m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].enableFocusNavigation = enableFocusNavigation
1✔
266
            end sub,
267

268
            ' ---------------------------------------------------------------------
269
            ' isFocusNavigationEnabled - Checks if focus navigation is currently enabled globally
270
            '
271
            ' @returns {boolean} True if enabled, false otherwise
272
            '
273
            isFocusNavigationEnabled: function() as boolean
274
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].enableFocusNavigation
1✔
275
            end function,
276

277
            ' ---------------------------------------------------------------------
278
            ' setFocus - Sets focus to this widget or another specified widget
279
            '
280
            ' @param {dynamic} isFocused - Boolean to focus/blur current widget, or string ID/HID of widget to focus
281
            ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node
282
            ' @returns {boolean} True if focus state was changed successfully, false otherwise
283
            '
284
            setFocus: function(command = true as dynamic, enableNativeFocus = false as boolean) as boolean
285
                plugin = m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY]
1✔
286
                HID = m.HID
1✔
287

288
                if Rotor.Utils.isString(command)
2✔
289
                    return plugin.setFocus(command, true, enableNativeFocus)
1✔
290
                else
3✔
291
                    return plugin.setFocus(HID, command, enableNativeFocus)
1✔
292
                end if
293
            end function,
294

295
            ' ---------------------------------------------------------------------
296
            ' getFocusedWidget - Retrieves the currently focused widget managed by this plugin
297
            '
298
            ' @returns {object} The widget instance that currently holds focus, or invalid
299
            '
300
            getFocusedWidget: function() as object
301
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].getFocusedWidget()
1✔
302
            end function,
303

304
            ' ---------------------------------------------------------------------
305
            ' proceedLongPress - Manually triggers the navigation action associated with the current long-press key
306
            '
307
            ' @returns {object} The result of the executed navigation action (see parseOnKeyEventResult)
308
            '
309
            proceedLongPress: function() as object
310
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].proceedLongPress()
×
311
            end function,
312

313
            ' ---------------------------------------------------------------------
314
            ' isLongPressActive - Checks if a long press action is currently active
315
            '
316
            ' @returns {boolean} True if a long press is active, false otherwise
317
            '
318
            isLongPressActive: function() as boolean
319
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].isLongPress
×
320
            end function,
321

322
            ' ---------------------------------------------------------------------
323
            ' triggerKeyPress - Simulate key press
324
            '
325
            ' @param {string} key - Pressed key
326
            ' @returns {object} The widget instance that currently holds focus, or invalid
327
            '
328
            triggerKeyPress: function(key) as object
329
                return m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY].onKeyEventHandler(key, true)
1✔
330
            end function,
331

332
            ' ---------------------------------------------------------------------
333
            ' setGroupLastFocusedId - Updates the lastFocusedHID of this widget's focus group
334
            '
335
            ' If called on a focusGroup widget, updates its own lastFocusedHID.
336
            ' If called on a focusItem widget, finds and updates the parent group's lastFocusedHID.
337
            '
338
            ' @param {string} id - The widget id to set as lastFocusedHID
339
            '
340
            setGroupLastFocusedId: sub(id as string)
341
                plugin = m.getFrameworkInstance().plugins[PRIMARY_FOCUS_PLUGIN_KEY]
1✔
342

343
                ' Determine ancestorHID for search context
344
                ' If this is a focus item, use parent group's HID to find siblings
345
                ancestorGroups = plugin.findAncestorGroups(m.HID)
1✔
346
                if ancestorGroups.count() > 0
2✔
347
                    ancestorHID = ancestorGroups[0]
1✔
348
                else
3✔
349
                    ancestorHID = m.HID
1✔
350
                end if
351

352
                ' Resolve id to HID - check focusItemStack first, then groupStack
353
                focusItem = plugin.focusItemStack.getByNodeId(id, ancestorHID)
1✔
354
                if focusItem <> invalid
2✔
355
                    resolvedHID = focusItem.HID
1✔
356
                else
3✔
357
                    group = plugin.groupStack.getByNodeId(id, ancestorHID)
1✔
358
                    if group <> invalid
3✔
359
                        resolvedHID = group.HID
1✔
360
                    else
3✔
361
                        return
1✔
362
                    end if
363
                end if
364

365
                ' Check if this widget is a group
366
                group = plugin.groupStack.get(m.HID)
1✔
367
                if group <> invalid
3✔
368
                    group.setLastFocusedHID(resolvedHID)
1✔
369
                    return
1✔
370
                end if
371

372
                ' This is a focus item - find parent group
373
                ancestorGroups = plugin.findAncestorGroups(m.HID)
1✔
374
                if ancestorGroups.count() > 0
3✔
375
                    parentGroupHID = ancestorGroups[0]
1✔
376
                    parentGroup = plugin.groupStack.get(parentGroupHID)
1✔
377
                    if parentGroup <> invalid
3✔
378
                        parentGroup.setLastFocusedHID(resolvedHID)
1✔
379
                    end if
380
                end if
381
            end sub
382

383
        }
384

385
        ' Configuration
386
        longPressDuration = 0.4
387
        enableLongPressFeature = true
388
        enableFocusNavigation = true
389

390
        ' State tracking
391
        globalFocusHID = ""
392
        globalFocusId = ""
393
        lastNavigationDirection = ""
394
        isLongPress = false
395
        longPressKey = ""
396

397
        ' References
398
        widgetTree as object
399
        frameworkInstance as Rotor.Framework
400

401
        ' Helper objects
402
        focusItemStack = new Rotor.FocusPluginHelper.FocusItemStack()
403
        groupStack = new Rotor.FocusPluginHelper.GroupStack()
404
        distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass()
405
        longPressTimer = CreateObject("roSGNode", "Timer")
406

407
        ' Spatial navigation direction validators (reused across calls)
408
        spatialValidators = {
409
            "left": function(segments as object, refSegmentLeft as object, refSegmentRight as object) as object
410
                right = segments[Rotor.Const.Segment.RIGHT]
1✔
411
                return right.x2 <= refSegmentLeft.x1 ? { isValid: true, segment: right } : { isValid: false }
1✔
412
            end function,
413
            "up": function(segments as object, refSegmentTop as object, refSegmentBottom as object) as object
414
                bottom = segments[Rotor.Const.Segment.BOTTOM]
1✔
415
                return bottom.y2 <= refSegmentTop.y1 ? { isValid: true, segment: bottom } : { isValid: false }
1✔
416
            end function,
417
            "right": function(segments as object, refSegmentLeft as object, refSegmentRight as object) as object
418
                left = segments[Rotor.Const.Segment.LEFT]
1✔
419
                return left.x1 >= refSegmentRight.x2 ? { isValid: true, segment: left } : { isValid: false }
1✔
420
            end function,
421
            "down": function(segments as object, refSegmentTop as object, refSegmentBottom as object) as object
422
                top = segments[Rotor.Const.Segment.TOP]
1✔
423
                return top.y1 >= refSegmentBottom.y2 ? { isValid: true, segment: top } : { isValid: false }
1✔
424
            end function
425
        }
426

427
        ' ---------------------------------------------------------------------
428
        ' init - Initializes the plugin instance
429
        '
430
        ' Sets up internal state and helpers.
431
        '
432
        sub init ()
433
            m.widgetTree = m.frameworkInstance.builder.widgetTree ' Reference to the main widget tree
1✔
434
            m.longPressTimer.addField("pluginKey", "string", false)
1✔
435
            m.longPressTimer.setFields({
1✔
436
                "pluginKey": m.pluginKey,
437
                duration: m.longPressDuration
438
            })
439
            ' Observe timer fire event to handle long press callback
440
            m.longPressTimer.observeFieldScoped("fire", "Rotor_FocusPluginHelper_longPressObserverCallback", ["pluginKey"])
1✔
441
        end sub
442

443
        '
444
        ' storeGlobalFocusHID - Stores the globally focused widget's HID and ID
445
        '
446
        ' @param {string} HID - The Hierarchical ID of the focused widget
447
        ' @param {string} id - The regular ID of the focused widget
448
        '
449
        sub storeGlobalFocusHID(HID as string, id as string)
450
            ' Store focus reference within the plugin
451
            m.globalFocusHID = HID
1✔
452
            m.globalFocusId = id
1✔
453
        end sub
454

455
        '
456
        ' getFocusedWidget - Gets the widget instance that currently holds global focus
457
        '
458
        ' @returns {object} The focused widget object, or invalid if none
459
        '
460
        function getFocusedWidget() as object
461
            return m.getFocusedItem()?.widget
1✔
462
        end function
463

464
        '
465
        ' getFocusedItem - Gets the FocusItem instance corresponding to the globally focused widget
466
        '
467
        ' @returns {object} The FocusItem instance, or invalid if none
468
        '
469
        function getFocusedItem() as object
470
            return m.focusItemStack.get(m.globalFocusHID)
1✔
471
        end function
472

473
        '
474
        ' setFocusConfig - Configures focus properties (FocusItem and/or Group) for a widget
475
        '
476
        ' @param {object} widget - The widget to configure
477
        ' @param {object} pluginConfig - The focus configuration object from the widget's spec
478
        '
479
        sub setFocusConfig(widget as object, pluginConfig as object)
480

481
            if pluginConfig = invalid then return ' No config provided
2✔
482
            HID = widget.HID
1✔
483
            id = widget.id
1✔
484

485
            ' Make a copy to avoid modifying the original config
486
            config = Rotor.Utils.deepCopy(pluginConfig)
1✔
487

488
            ' Ensure essential identifiers are in the config
489
            config.id = id
1✔
490
            config.HID = widget.HID
1✔
491

492
            ' Handle group configuration if present
493
            if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY)
3✔
494
                ' Handle focus item configuration if applicable
495
                m.setupFocusItem(HID, config, widget)
1✔
496
            else
497
                ' Handle group configuration
3✔
498
                m.setupGroup(HID, config, widget)
1✔
499
            end if
500
        end sub
501

502
        '
503
        ' setupGroup - Creates and registers a new Focus Group based on configuration
504
        '
505
        ' @param {string} HID - The Hierarchical ID of the widget acting as the group root
506
        ' @param {object} config - The full focus configuration for the widget
507
        ' @param {object} widget - The widget instance itself
508
        '
509
        sub setupGroup(HID as string, config as object, widget as object)
510
            ' Copy essential info to the group-specific config
511
            config.id = config.id
1✔
512
            config.HID = config.HID
1✔
513
            config.widget = widget
1✔
514
            ' Create and configure the Group instance
515
            newGroup = new Rotor.FocusPluginHelper.GroupClass(config)
1✔
516
            newGroup.focusItemsRef = m.focusItemStack ' Provide reference to focus items
1✔
517
            newGroup.groupsRef = m.groupStack ' Provide reference to other groups
1✔
518
            m.groupStack.set(config.HID, newGroup) ' Register the new group
1✔
519
        end sub
520

521
        '
522
        ' setupFocusItem - Creates and registers a new Focus Item based on configuration
523
        '
524
        ' @param {string} HID - The Hierarchical ID of the focusItem widget
525
        ' @param {object} config - The full focus configuration for the widget
526
        ' @param {object} widget - The widget instance itself
527
        '
528
        sub setupFocusItem(HID as string, config as object, widget as object)
529
            config.widget = widget ' Ensure widget reference is in the config
1✔
530

531
            ' Create and register the FocusItem instance
532
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
1✔
533
            m.focusItemStack.set(HID, newFocusItem)
1✔
534

535
            ' Restore focus state if this widget had global focus
536
            if m.globalFocusHID = HID
2✔
537
                newFocusItem.isFocused = true
1✔
538
            end if
539
        end sub
540

541
        '
542
        ' findAncestorGroups - Finds all ancestor groups for a given widget HID
543
        '
544
        ' @param {string} HID - The Hierarchical ID of the widget
545
        ' @returns {object} An roArray of ancestor group HIDs, sorted with the immediate parent first (descending HID length)
546
        '
547
        function findAncestorGroups(HID as string) as object
548
            allGroups = m.groupStack.getAll() ' Get all registered groups
1✔
549
            ancestorGroups = []
1✔
550
            ' Iterate through all groups to find ancestors
551
            for each groupHID in allGroups
1✔
552
                if Rotor.Utils.isAncestorHID(groupHID, HID)
2✔
553
                    ancestorGroups.push(groupHID)
1✔
554
                end if
555
            end for
556
            ' Sort by HID length descending (parent first)
557
            ancestorGroups.Sort("r")
1✔
558

559
            ' Note:
560
            ' - Parent group is at index 0
561
            ' - If HID is a focusItem, its direct parent group is included
562
            ' - If HID is a group, the group itself is NOT included
563
            return ancestorGroups
1✔
564
        end function
565

566
        '
567
        ' removeFocusConfig - Removes focus configuration (Group and/or FocusItem) for a widget
568
        '
569
        ' @param {string} HID - The Hierarchical ID of the widget whose config should be removed
570
        '
571
        sub removeFocusConfig(HID as string)
572
            ' Remove associated group, if it exists
573
            if m.groupStack.has(HID)
2✔
574
                m.groupStack.remove(HID)
1✔
575
            end if
576
            ' Remove associated focus item, if it exists
577
            if m.focusItemStack.has(HID)
2✔
578
                m.focusItemStack.remove(HID)
1✔
579
            end if
580
        end sub
581

582
        '
583
        ' setFocus - Sets or removes focus from a specific widget or group
584
        '
585
        ' Handles focus state changes, callbacks, and native focus interaction.
586
        '
587
        ' @param {dynamic} ref - The target: HID (string) of a FocusItem or Group, or Node ID (string) of a Group
588
        ' @param {boolean} isFocused - True to set focus, false to remove focus (default: true)
589
        ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node (default: false)
590
        ' @returns {boolean} True if the focus state was successfully changed, false otherwise
591
        '
592
        function setFocus(ref as dynamic, isFocused = true as boolean, enableNativeFocus = false as boolean) as boolean
593

594
            ' Resolve reference (HID or ID) to a focusItem item.
595
            focusItem = invalid ' Initialize target focus item
1✔
596

597
            ' Exit if reference is empty or invalid.
598
            if ref = invalid or ref = "" then return false
2✔
599

600
            if m.focusItemStack.has(ref)
2✔
601
                ' Case 1: ref is a valid focusItem HID.
602
                focusItem = m.focusItemStack.get(ref)
1✔
603
            else
604
                ' Case 2: ref might be a focusItem node ID.
3✔
605
                focusItem = m.focusItemStack.getByNodeId(ref)
1✔
606

607
                if focusItem = invalid
3✔
608
                    ' Case 3: ref might be a group HID or group node ID.
609
                    ' Try finding group by HID first, then by Node ID.
610
                    group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
1✔
611
                    if group <> invalid
3✔
612
                        ' If group found, find its default/entry focus item recursively.
613
                        ' Use lastNavigationDirection so enableSpatialEnter groups can pick the right entry point
614
                        HID = m.capturingFocus_recursively(group.HID, m.lastNavigationDirection)
1✔
615
                        focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
1✔
616
                        ' else: ref is not a known FocusItem HID or Group identifier
617
                    end if
618
                end if
619
            end if
620

621
            ' Handle case where the target focus item could not be found or resolved.
622
            if focusItem = invalid
2✔
623
                focused = m.focusItemStack.get(m.globalFocusHID) ' Check current focus
1✔
624
                #if debug
4✔
625
                    ' Log warnings if focus target is not found
626
                    if focused = invalid
2✔
627
                        print `[PLUGIN][FOCUS][WARNING] Requested focus target ref: "${ref}" was not found or resolved to a valid FocusItem.`
×
628
                        if m.globalFocusHID = ""
×
629
                            ' If global focus is also lost, indicate potential issue.
630
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue likely. No current focus set. Ensure valid initial focus.`
×
631
                        else
×
632
                            print `[PLUGIN][FOCUS][WARNING] Current focus HID: "${m.globalFocusHID}". Ensure target "${ref}" is registered and reachable.`
×
633
                        end if
634
                    else
3✔
635
                        print `[PLUGIN][FOCUS][WARNING] Could not find focus target ref: "${ref}". Current focus remains on HID: "${m.globalFocusHID}", id"${m.globalFocusId}"".`
1✔
636
                    end if
637
                #end if
638
                return false ' Indicate focus change failed
1✔
639
            end if
640

641
            ' Found a valid focusItem to target
642
            HID = focusItem.HID
1✔
643

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

648
            ' Cannot focus an invisible item.
649
            if focusItem.node.visible = false and isFocused = true then return false
2✔
650

651
            ' Determine if native focus should be enabled (request or item default)
652
            enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
1✔
653

654
            ' Prevent focusing a disabled item.
655
            preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
1✔
656
            if preventFocusOnDisabled
2✔
657
                return false ' Indicate focus change failed
×
658
            end if
659

660
            ' Prepare ancestor groups for notification (from highest ancestor to closest parent)
661
            focusChainGroups = m.findAncestorGroups(focusItem.HID) ' Groups containing the new focus
1✔
662

663
            lastFocusChainingGroups = []
1✔
664

665
            ' Handle blurring the previously focused item
666
            if m.globalFocusHID <> "" ' If something was focused before
2✔
667
                lastFocused = m.focusItemStack.get(m.globalFocusHID)
1✔
668
                if lastFocused <> invalid ' Check if the last focused widget hasn't been destroyed
3✔
669
                    ' Record the last focused item within its parent group for potential future use (e.g., returning focus)
670
                    lastFocusChainingGroups = m.findAncestorGroups(m.globalFocusHID)
1✔
671
                    for i = 0 to lastFocusChainingGroups.Count() - 1
1✔
672
                        ancestorGroupHID = lastFocusChainingGroups[i]
1✔
673
                        ancestorGroup = m.groupStack.get(ancestorGroupHID)
1✔
674
                        if ancestorGroup <> invalid
3✔
675
                            ' For immediate parent (index 0): set if enableLastFocusId is true (default)
676
                            ' For other ancestors: set if enableDeepLastFocusId is enabled
677
                            shouldSetLastFocusId = (i = 0 and ancestorGroup.enableLastFocusId) or (i > 0 and ancestorGroup.enableDeepLastFocusId)
1✔
678
                            if shouldSetLastFocusId
2✔
679
                                ancestorGroup.setLastFocusedHID(m.globalFocusHID)
1✔
680
                            end if
681
                        end if
682
                    end for
683
                end if
684
            end if
685

686
            ' Prepare notification list: all affected groups (unique)
687
            allAffectedGroups = []
1✔
688
            for each groupHID in focusChainGroups
1✔
689
                allAffectedGroups.unshift(groupHID) ' Add in reverse order (highest ancestor first)
1✔
690
            end for
691
            for i = 0 to lastFocusChainingGroups.Count() - 1
1✔
692
                groupHID = lastFocusChainingGroups[i]
1✔
693

694
                ' Add to allAffectedGroups if not present
695
                if -1 = Rotor.Utils.findInArray(allAffectedGroups, groupHID)
2✔
696
                    allAffectedGroups.unshift(groupHID)
1✔
697
                end if
698
            end for
699

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

703
            ' Blur the previously focused item (after notification)
704
            if m.globalFocusHID <> "" and lastFocused <> invalid
2✔
705
                lastFocused.applyFocus(false, enableNativeFocus)
1✔
706
            end if
707

708
            ' Apply focus state (focused/blurred) to the target item.
709
            focusItem.applyFocus(isFocused, enableNativeFocus)
1✔
710

711
            ' Update the globally tracked focused item.
712
            m.storeGlobalFocusHID(isFocused ? HID : "", isFocused ? focusItem.id : "")
1✔
713

714
            ' Ensure SceneGraph root has focus if native focus wasn't explicitly enabled on the item.
715
            if enableNativeFocus = false
3✔
716
                globalScope = GetGlobalAA()
1✔
717
                if globalScope.top.isInFocusChain() = false
2✔
718
                    globalScope.top.setFocus(true)
1✔
719
                end if
720
            end if
721

722
            return true
1✔
723

724
        end function
725

726
        '
727
        ' notifyFocusAtAncestorGroups - Applies the correct focus state (in focus chain or not) to a list of group HIDs
728
        '
729
        ' @param {string} HID - The HID of the item that ultimately received/lost focus
730
        ' @param {object} groupHIDs - An roArray of group HIDs to notify
731
        '
732
        sub notifyFocusAtAncestorGroups(HID as string, groupHIDs = [] as object)
733

734
            ' Notify all ancestor groups
735
            if groupHIDs.Count() > 0
3✔
736
                for each groupHID in groupHIDs
1✔
737

738
                    group = m.groupStack.get(groupHID)
1✔
739
                    isInFocusChain = Rotor.Utils.isAncestorHID(groupHID, HID)
1✔
740
                    group.applyFocus(isInFocusChain)
1✔
741

742
                end for
743
            end if
744
        end sub
745

746
        sub notifyLongPressAtAncestorGroups(isLongPress as boolean, key as string, HID as string, groupHIDs = [] as object)
747
            ' Notify all ancestor groups
748
            if groupHIDs.Count() > 0
3✔
749
                for each groupHID in groupHIDs
1✔
750
                    group = m.groupStack.get(groupHID)
1✔
751
                    handled = group.callLongPressHandler(isLongPress, key)
1✔
752
                    if handled then exit for
2✔
753
                end for
754
            end if
755
        end sub
756

757
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
758
            focused = m.getFocusedItem()
1✔
759
            handled = focused.callLongPressHandler(isLongPress, key)
1✔
760
            if handled then return
2✔
761

762
            focusChainGroups = m.findAncestorGroups(focused.HID)
1✔
763
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
1✔
764
        end sub
765

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

771
            ' Remove current focused item from candidates
772
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
1✔
773
            if index >= 0 then focusItemsHIDlist.delete(index)
2✔
774

775
            ' Find closest focusable item in direction
776
            focusedMetrics = focused.refreshBounding()
1✔
777
            segments = m.collectSegments(focusedMetrics, direction, focusItemsHIDlist, focused.HID)
1✔
778
            if segments.Count() > 0
3✔
779
                return m.findClosestSegment(segments, focusedMetrics.middlePoint)
1✔
780
            end if
781

782
            return ""
×
783
        end function
784

785
        function findClosestSegment(segments as object, middlePoint as object) as string
786
            distances = []
1✔
787

788
            ' Calculate distance from middle point to each segment
789
            for each HID in segments
1✔
790
                segment = segments[HID]
1✔
791
                distance = m.distanceCalculator.distToSegment(middlePoint, {
1✔
792
                    x: segment.x1,
793
                    y: segment.y1
794
                }, {
795
                    x: segment.x2,
796
                    y: segment.y2
797
                })
798

799
                distances.push({
1✔
800
                    HID: HID,
801
                    distance: distance
802
                })
803
            end for
804

805
            ' Find segment with minimum distance
806
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
1✔
807
                return a < b
808
            end function)
809

810
            return minDistItem.HID
1✔
811
        end function
812

813

814
        ' Waterfall of fallback's of groups (linked together with defaultFocusId)
815
        function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
816
            ' Resolve identifier to a group
817
            group = m.groupStack.get(identifier)
1✔
818
            if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
1✔
819
            if group = invalid then return ""
2✔
820

821
            newHID = ""
1✔
822

823
            ' enableSpatialEnter: use spatialNavigation to find closest member
824
            if group.isSpatialEnterEnabledForDirection(direction)
2✔
825
                if direction <> "" and m.globalFocusHID <> ""
3✔
826
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
827
                    if focused <> invalid
3✔
828
                        ' Get group members and use spatial navigation to find closest
829
                        members = group.getGroupMembersHIDs()
1✔
830
                        if members.count() > 0
3✔
831
                            newHID = m.spatialNavigation(focused, direction, members, true)
1✔
832
                            ' If spatial nav found a group (not a focus item), recursively resolve it
833
                            if newHID <> "" and m.groupStack.has(newHID)
2✔
834
                                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
×
835
                            end if
836
                        end if
837
                    end if
838
                end if
839
            end if
840

841
            ' Fallback to getFallbackIdentifier if spatial enter didn't find anything
842
            if newHID = ""
3✔
843
                newHID = group.getFallbackIdentifier(m.globalFocusHID, direction)
1✔
844
            end if
845

846
            ' Check if we found a FocusItem
847
            if m.focusItemStack.has(newHID)
3✔
848
                ' noop — direct focusItem resolved
849
            else if newHID <> ""
3✔
850
                ' Try to find as group first, then deep search
851
                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
1✔
852

853
                ' If still not found, perform deep search in all descendants
854
                if newHID = ""
2✔
855
                    newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
1✔
856
                end if
857
            end if
858

859
            ' Prevent capturing by fallback in the same group where original focus was
860
            ' Skip this guard for enableSpatialEnter groups (spatial enter explicitly targets a sibling group's member)
861
            if not group.isSpatialEnterEnabledForDirection(direction) and newHID <> "" and m.globalFocusHID <> ""
2✔
862
                currentAncestors = m.findAncestorGroups(m.globalFocusHID)
1✔
863
                newAncestors = m.findAncestorGroups(newHID)
1✔
864
                if currentAncestors.Count() > 0 and newAncestors.Count() > 0
3✔
865
                    if currentAncestors[0] = newAncestors[0] then newHID = ""
2✔
866
                end if
867
            end if
868

869
            return newHID
1✔
870
        end function
871

872
        '
873
        ' deepSearchFocusItemByNodeId - Deep search for a FocusItem or Group by nodeId within a group hierarchy
874
        '
875
        ' @param {string} groupHID - The HID of the group to search within
876
        ' @param {string} nodeId - The node ID to search for
877
        ' @returns {string} The HID of the found FocusItem or Group, or empty string if not found
878
        '
879
        function deepSearchFocusItemByNodeId(groupHID as string, nodeId as string) as string
880
            if nodeId = "" then return ""
1✔
881

882
            ' Get all descendants of this group (both FocusItems and nested Groups)
883
            allFocusItems = m.focusItemStack.getAll()
1✔
884
            allGroups = m.groupStack.getAll()
1✔
885

886
            ' First, search in direct and nested FocusItems
887
            for each focusItemHID in allFocusItems
1✔
888
                if Rotor.Utils.isDescendantHID(focusItemHID, groupHID)
3✔
889
                    focusItem = m.focusItemStack.get(focusItemHID)
1✔
890
                    if focusItem <> invalid and focusItem.id = nodeId
2✔
891
                        return focusItemHID
1✔
892
                    end if
893
                end if
894
            end for
895

896
            ' Second, search in nested Groups (and if found, apply fallback logic on that group)
897
            for each nestedGroupHID in allGroups
1✔
898
                if Rotor.Utils.isDescendantHID(nestedGroupHID, groupHID) and nestedGroupHID <> groupHID
2✔
899
                    nestedGroup = m.groupStack.get(nestedGroupHID)
1✔
900
                    if nestedGroup <> invalid and nestedGroup.id = nodeId
2✔
901
                        ' Found a matching group - now apply fallback logic on it
902
                        fallbackHID = nestedGroup.getFallbackIdentifier()
1✔
903
                        if m.focusItemStack.has(fallbackHID)
3✔
904
                            return fallbackHID
1✔
905
                        else if fallbackHID <> ""
×
906
                            ' Recursively resolve the fallback
907
                            return m.capturingFocus_recursively(fallbackHID, "", nestedGroupHID)
×
908
                        end if
909
                    end if
910
                end if
911
            end for
912

913
            return ""
1✔
914
        end function
915

916
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
917
            newHID = ""
1✔
918

919
            ' Build ancestor chain (current group + all ancestors)
920
            ancestorGroups = m.findAncestorGroups(groupHID)
1✔
921
            ancestorGroups.unshift(groupHID)
1✔
922
            ancestorGroupsCount = ancestorGroups.Count()
1✔
923
            ancestorIndex = 0
1✔
924

925
            ' Get currently focused item for spatial navigation
926
            focused = m.focusItemStack.get(m.globalFocusHID)
1✔
927

928
            ' Bubble up through ancestor groups until we find a target or reach the top
929
            while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
1✔
930
                ' Get next ancestor group
931
                groupHID = ancestorGroups[ancestorIndex]
1✔
932
                group = m.groupStack.get(groupHID)
1✔
933

934
                ' Check group's direction configuration
935
                nodeId = group.getStaticNodeIdInDirection(direction)
1✔
936

937
                if Rotor.Utils.isBoolean(nodeId)
2✔
938
                    ' Boolean means focus is explicitly handled
939
                    if nodeId = true
3✔
940
                        newHID = true ' Block navigation (exit loop)
1✔
941
                    else
×
942
                        newHID = "" ' Continue bubbling
×
943
                    end if
944
                else
945
                    ' String nodeId - try to resolve target
3✔
946
                    if nodeId <> ""
3✔
947
                        otherGroup = m.groupStack.getByNodeId(nodeId)
1✔
948
                        if otherGroup <> invalid
3✔
949
                            newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
1✔
950
                        end if
951
                    else
952
                        ' No explicit direction - try spatial navigation at this group level
953
                        ' This allows navigation between sibling child groups with enableSpatialNavigation
954
                        ' Skip during long press to allow bubbling up to parent carousel for continuous scrolling
955
                        ' Skip for "back" direction - spatial navigation doesn't apply
3✔
956
                        if focused <> invalid and m.isLongPress = false and direction <> Rotor.Const.Direction.BACK
3✔
957
                            groupMembers = group.getGroupMembersHIDs()
1✔
958
                            ' Check if this group has any child groups with spatial nav enabled
959
                            hasSpatialNavGroups = false
1✔
960
                            for each memberHID in groupMembers
1✔
961
                                if m.groupStack.has(memberHID)
2✔
962
                                    memberGroup = m.groupStack.get(memberHID)
×
963
                                    if memberGroup.enableSpatialNavigation = true
×
964
                                        hasSpatialNavGroups = true
×
965
                                        exit for
966
                                    end if
967
                                end if
968
                            end for
969
                            ' If there are spatial-nav-enabled child groups, try spatial navigation
970
                            ' Use bypassFocusedCheck=true since we're navigating between groups, not within
971
                            if hasSpatialNavGroups
2✔
972
                                newHID = m.spatialNavigation(focused, direction, groupMembers, true)
×
973
                                ' If spatial nav found a group, use capturing focus to enter it
974
                                if newHID <> "" and m.groupStack.has(newHID)
×
975
                                    newHID = m.capturingFocus_recursively(newHID, direction)
×
976
                                end if
977
                            end if
978
                        end if
979
                    end if
980
                end if
981

982
                ancestorIndex++
1✔
983
            end while
984

985
            return newHID
1✔
986
        end function
987

988
        ' * KEY EVENT HANDLER
989
        function onKeyEventHandler(key as string, press as boolean) as object
990
            ' Check long-press
991
            if m.enableLongPressFeature = true
2✔
992
                m.checkLongPressState(key, press)
1✔
993
            end if
994
            ' Prevent any navigation if it is disabled
995
            #if debug
4✔
996
            #end if
997
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
2✔
998
            ' Execute action according to key press
999
            return m.executeNavigationAction(key, press)
1✔
1000
        end function
1001

1002
        function executeNavigationAction(key as string, press as boolean) as object
1003

1004
            if true = press
3✔
1005

1006
                if -1 < Rotor.Utils.findInArray([
3✔
1007
                        Rotor.Const.Direction.UP,
1008
                        Rotor.Const.Direction.RIGHT,
1009
                        Rotor.Const.Direction.DOWN,
1010
                        Rotor.Const.Direction.LEFT,
1011
                        Rotor.Const.Direction.BACK
1012
                    ], key)
1013

1014
                    newHID = ""
1✔
1015
                    direction = key
1✔
1016
                    m.lastNavigationDirection = direction
1✔
1017

1018
                    ' (1) Pick up current focused item
1019

1020
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
1021

1022
                    if focused = invalid
2✔
1023
                        #if debug
×
1024
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue detected. Last known focus id:\"${m.globalFocusHID}\". Please ensure valid focus.`
×
1025
                        #end if
1026
                        return m.parseOnKeyEventResult(key, false, false)
×
1027
                    end if
1028

1029

1030
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
1031
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
1032

1033
                    if ancestorGroupsCount = 0
2✔
1034
                        allFocusItems = m.focusItemStack.getAll()
1✔
1035
                        possibleFocusItems = allFocusItems.keys()
1✔
1036
                        parentGroupHID = ""
1✔
1037
                    else
3✔
1038
                        parentGroupHID = ancestorGroups[0]
1✔
1039
                        group = m.groupStack.get(parentGroupHID)
1✔
1040
                        possibleFocusItems = group.getGroupMembersHIDs()
1✔
1041
                    end if
1042

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

1046
                    if Rotor.Utils.isBoolean(nodeId) and nodeId = true
2✔
1047
                        ' It means that focus is handled, and no need further action by plugin.
1048
                        return m.parseOnKeyEventResult(key, true, false)
×
1049
                    end if
1050

1051
                    if nodeId <> ""
2✔
1052
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
1053
                    end if
1054

1055
                    if newHID = ""
3✔
1056
                        ' (3) Try spatial navigation in direction, among possible focusItems
1057
                        ' all = m.focusItemStack.getAll()
1058
                        ' allKeys = all.Keys()
1059
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
1✔
1060
                    end if
1061

1062
                    ' (4) Check if found group. FocusItem can not point out of group.
1063
                    if newHID = "" and ancestorGroupsCount > 0 ' (5/2) If this focused has parent group, lets try bubbling focus on ancestors (groups)
3✔
1064
                        newHID = m.bubblingFocus(parentGroupHID, direction)
1✔
1065
                        if Rotor.Utils.isBoolean(newHID)
2✔
1066
                            if newHID = true
3✔
1067
                                ' It means that focus is handled, and no need further action by plugin.
1068
                                return m.parseOnKeyEventResult(key, true, false)
1✔
1069
                            else
×
1070
                                newHID = ""
×
1071
                            end if
1072
                        end if
1073
                    end if
1074

1075
                    handled = m.setFocus(newHID)
1✔
1076
                    return m.parseOnKeyEventResult(key, handled, false)
1✔
1077

1078
                else if key = "OK"
3✔
1079

1080
                    return m.parseOnKeyEventResult(key, true, true)
1✔
1081

1082
                end if
1083
            end if
1084

1085
            return m.parseOnKeyEventResult(key, false, false)
×
1086

1087
        end function
1088

1089
        function parseOnKeyEventResult(key as string, handled as boolean, isSelected as boolean) as object
1090
            result = {
1✔
1091
                handled: handled,
1092
                key: key
1093
            }
1094
            if m.globalFocusHID <> "" and handled = true
3✔
1095
                focusItem = m.focusItemStack.get(m.globalFocusHID)
1✔
1096
                widget = m.widgetTree.get(focusItem.HID)
1✔
1097
                ' viewModelState = Rotor.Utils.deepCopy(widget.viewModelState)
1098
                result.widget = widget
1✔
1099
                if isSelected
2✔
1100
                    result.isSelected = isSelected
1✔
1101
                    focusItem.callOnSelectFnOnWidget()
1✔
1102
                end if
1103
            end if
1104
            return result
1✔
1105
        end function
1106

1107
        sub checkLongPressState(key as string, press as boolean)
1108
            m.longPressTimer.control = "stop"
1✔
1109
            if press = true
3✔
1110
                if m.isLongPress = false
3✔
1111
                    m.longPressKey = key
1✔
1112
                    m.longPressTimer.control = "start"
1✔
1113
                end if
1114
            else
×
1115
                wasLongPress = m.isLongPress = true
×
1116
                lastKey = m.longPressKey
×
1117
                m.isLongPress = false
×
1118
                m.longPressKey = ""
×
1119
                if wasLongPress
×
1120
                    m.delegateLongPressChanged(false, lastKey)
×
1121
                end if
1122
            end if
1123
        end sub
1124

1125
        function proceedLongPress() as object
1126
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(m.longPressKey, false, false)
×
1127
            return m.executeNavigationAction(m.longPressKey, true)
×
1128
        end function
1129

1130
        ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
1131
        ' @param focusedMetrics - The metrics object from focused.refreshBounding()
1132
        ' @param focusedHID - The HID of the focused item (to exclude from candidates)
1133
        function collectSegments(focusedMetrics as object, direction as string, focusItemsHIDlist as object, focusedHID as string) as object
1134
            refSegments = focusedMetrics.segments
1✔
1135
            refSegmentTop = refSegments[Rotor.Const.Segment.TOP]
1✔
1136
            refSegmentRight = refSegments[Rotor.Const.Segment.RIGHT]
1✔
1137
            refSegmentLeft = refSegments[Rotor.Const.Segment.LEFT]
1✔
1138
            refSegmentBottom = refSegments[Rotor.Const.Segment.BOTTOM]
1✔
1139

1140
            segments = {}
1✔
1141
            validator = m.spatialValidators[direction]
1✔
1142
            for each HID in focusItemsHIDlist
1✔
1143
                if HID <> focusedHID
3✔
1144
                    ' Try to get as FocusItem first, then as Group
1145
                    candidate = m.focusItemStack.get(HID)
1✔
1146
                    isGroup = false
1✔
1147
                    if candidate = invalid
2✔
1148
                        candidate = m.groupStack.get(HID)
×
1149
                        isGroup = true
×
1150
                    end if
1151
                    if candidate = invalid then continue for
2✔
1152

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

1156
                    candidateMetrics = candidate.refreshBounding()
1✔
1157
                    ' Pass appropriate reference segments based on direction
1158
                    if direction = "left" or direction = "right"
3✔
1159
                        result = validator(candidateMetrics.segments, refSegmentLeft, refSegmentRight)
1✔
1160
                    else ' up or down
3✔
1161
                        result = validator(candidateMetrics.segments, refSegmentTop, refSegmentBottom)
1✔
1162
                    end if
1163
                    if result.isValid
3✔
1164
                        segments[HID] = result.segment
1✔
1165
                    end if
1166
                end if
1167
            end for
1168

1169
            return segments
1✔
1170
        end function
1171

1172
        sub destroy()
1173
            ' Remove all groups
1174
            for each HID in m.groupStack.getAll()
1✔
1175
                m.groupStack.remove(HID)
1✔
1176
            end for
1177
            ' Remove all focus items
1178
            for each HID in m.focusItemStack.getAll()
1✔
1179
                m.focusItemStack.remove(HID)
1✔
1180
            end for
1181
            m.longPressTimer.unobserveFieldScoped("fire")
1✔
1182
            m.longPressTimer = invalid
1✔
1183
            m.widgetTree = invalid
1✔
1184
        end sub
1185

1186
    end class
1187

1188
    namespace FocusPluginHelper
1189

1190
        class BaseEntryStack extends Rotor.BaseStack
1191

1192
            function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
1193
                if ancestorHID <> "0"
3✔
1194
                    filteredStack = {}
1✔
1195
                    for each HID in m.stack
1✔
1196
                        if Rotor.Utils.isDescendantHID(HID, ancestorHID)
3✔
1197
                            filteredStack[HID] = m.get(HID)
1✔
1198
                        end if
1199
                    end for
1200
                else
3✔
1201
                    filteredStack = m.stack
1✔
1202
                end if
1203
                HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
1✔
1204
                return HID <> "" ? m.get(HID) : invalid
1✔
1205
            end function
1206

1207
            override sub remove(HID as string)
1208
                item = m.get(HID)
1✔
1209
                item.destroy()
1✔
1210
                super.remove(HID)
1✔
1211
            end sub
1212

1213
        end class
1214

1215
        class GroupStack extends BaseEntryStack
1216

1217
            function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1218
                foundHID = ""
×
1219
                for each HID in possibleGroups
×
1220
                    group = m.get(HID)
×
1221
                    if group.id = nodeId
×
1222
                        foundHID = group.HID
×
1223
                        exit for
1224
                    end if
1225
                end for
1226
                return foundHID
×
1227
            end function
1228

1229
        end class
1230

1231

1232
        class FocusItemStack extends BaseEntryStack
1233

1234
            function convertNodeIdToHID(nodeId as string, possibleFocusItems as object) as string
1235
                foundHID = ""
×
1236
                for each HID in possibleFocusItems
×
1237
                    focusItem = m.get(HID)
×
1238
                    if focusItem?.id = nodeId
×
1239
                        foundHID = focusItem.HID
×
1240
                        exit for
1241
                    end if
1242
                end for
1243
                return foundHID
×
1244
            end function
1245

1246
            function hasEnabled(HID as string) as boolean
1247
                if m.has(HID)
3✔
1248
                    focusItem = m.get(HID)
1✔
1249
                    return focusItem.isEnabled
1✔
1250
                else
3✔
1251
                    return false
1✔
1252
                end if
1253
            end function
1254

1255
        end class
1256

1257
        class BaseFocusConfig
1258

1259
            autoSetIsFocusedState as boolean
1260
            staticDirection as object
1261

1262
            sub new (config as object)
1263

1264
                m.HID = config.HID
1✔
1265
                m.id = config.id
1✔
1266

1267
                m.widget = config.widget
1✔
1268
                m.node = m.widget.node
1✔
1269
                m.isFocused = config.isFocused ?? false
1✔
1270

1271
                m.autoSetIsFocusedState = config.autoSetIsFocusedState ?? true
1✔
1272

1273
                m.isEnabled = config.isEnabled ?? true
1✔
1274
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? false
1✔
1275
                m.staticDirection = {}
1✔
1276
                m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1✔
1277
                m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
1✔
1278
                m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
1✔
1279
                m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
1✔
1280
                m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
1✔
1281

1282
                m.onFocusChanged = config.onFocusChanged
1✔
1283
                m.longPressHandler = config.longPressHandler
1✔
1284
                m.onFocus = config.onFocus
1✔
1285
                m.onBlur = config.onBlur
1✔
1286

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

1289
                ' convenience (usually this is used on viewModelState)
1290
                if false = m.widget.viewModelState.DoesExist("isFocused") and true = m.autoSetIsFocusedState
2✔
1291
                    m.widget.viewModelState.isFocused = false ' as default
1✔
1292
                end if
1293

1294
            end sub
1295

1296

1297
            HID as string
1298
            id as string
1299
            idByKeys as object
1300
            isEnabled as boolean
1301
            isFocused as boolean
1302
            enableSpatialNavigation as boolean
1303
            onFocusChanged as dynamic
1304
            onFocus as dynamic
1305
            onBlur as dynamic
1306
            longPressHandler as dynamic
1307
            node as object
1308
            widget as object
1309

1310
            protected metrics = {
1311
                segments: {}
1312
            }
1313

1314
            function refreshBounding() as object
1315
                b = m.node.sceneBoundingRect()
×
1316
                rotation = m.node.rotation
×
1317

1318
                if rotation = 0
×
1319
                    if b.y = 0 and b.x = 0
×
1320
                        t = m.node.translation
×
1321
                        b.x += t[0]
×
1322
                        b.y += t[1]
×
1323
                    end if
1324

1325
                    m.metrics.append(b)
×
1326
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
×
1327
                        x1: b.x, y1: b.y,
1328
                        x2: b.x, y2: b.y + b.height
1329
                    }
1330
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
×
1331
                        x1: b.x, y1: b.y,
1332
                        x2: b.x + b.width, y2: b.y
1333
                    }
1334
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
×
1335
                        x1: b.x + b.width, y1: b.y,
1336
                        x2: b.x + b.width, y2: b.y + b.height
1337
                    }
1338
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
×
1339
                        x1: b.x, y1: b.y + b.height,
1340
                        x2: b.x + b.width, y2: b.y + b.height
1341
                    }
1342
                    m.metrics.middlePoint = {
×
1343
                        x: b.x + b.width / 2,
1344
                        y: b.y + b.height / 2
1345
                    }
1346
                end if
1347

1348
                return m.metrics
×
1349
            end function
1350

1351
            function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1352
                direction = m.staticDirection[direction]
1✔
1353
                if Rotor.Utils.isFunction(direction)
2✔
1354
                    return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
×
1355
                else
3✔
1356
                    return direction ?? ""
1✔
1357
                end if
1358
            end function
1359

1360
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1361
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1362
                if true = isFocused
3✔
1363
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1364
                else
3✔
1365
                    Rotor.Utils.callbackScoped(m.onBlur, m.widget)
1✔
1366
                end if
1367
            end sub
1368

1369
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1370
                if Rotor.Utils.isFunction(m.longPressHandler)
1371
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1372
                else
3✔
1373
                    return false
1✔
1374
                end if
1375
            end function
1376

1377
            sub destroy()
1378
                m.widget = invalid
1✔
1379
                m.node = invalid
1✔
1380
                m.onFocusChanged = invalid
1✔
1381
                m.onFocus = invalid
1✔
1382
                m.onBlur = invalid
1✔
1383
                m.longPressHandler = invalid
1✔
1384
            end sub
1385

1386
        end class
1387

1388
        class GroupClass extends BaseFocusConfig
1389
            ' Note: Spatial navigation is supported within group, there is no spatial navigation between groups
1390
            ' If you want to focus out to another group, you need to config a direction prop.
1391
            ' You can set a groupId or any focusItem widgetId.
1392
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1393
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1394

1395
            sub new (config as object)
1396
                super(config)
1✔
1397
                m.defaultFocusId = config.defaultFocusId ?? ""
1✔
1398
                m.lastFocusedHID = config.lastFocusedHID ?? ""
1✔
1399
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
1✔
1400
                m.enableLastFocusId = config.enableLastFocusId ?? true
1✔
1401
                m.enableDeepLastFocusId = config.enableDeepLastFocusId ?? false
1✔
1402
            end sub
1403

1404
            defaultFocusId as string
1405
            lastFocusedHID as string
1406
            enableSpatialEnter as dynamic ' boolean | { up?: boolean, down?: boolean, left?: boolean, right?: boolean }
1407
            enableLastFocusId as boolean
1408
            enableDeepLastFocusId as boolean
1409

1410
            '
1411
            ' isSpatialEnterEnabledForDirection - Checks if spatial enter is enabled for a specific direction
1412
            '
1413
            ' @param {string} direction - The direction to check (up, down, left, right)
1414
            ' @returns {boolean} True if spatial enter is enabled for the direction
1415
            '
1416
            function isSpatialEnterEnabledForDirection(direction as string) as boolean
1417
                if Rotor.Utils.isBoolean(m.enableSpatialEnter)
3✔
1418
                    return m.enableSpatialEnter
1✔
1419
                else if Rotor.Utils.isAssociativeArray(m.enableSpatialEnter)
3✔
1420
                    return m.enableSpatialEnter[direction] = true
1✔
1421
                end if
1422
                return false
×
1423
            end function
1424
            focusItemsRef as object
1425
            groupsRef as object
1426
            isFocusItem = false
1427
            isGroup = true
1428

1429
            sub setLastFocusedHID(lastFocusedHID as string)
1430
                m.lastFocusedHID = lastFocusedHID
1✔
1431
            end sub
1432

1433
            function getGroupMembersHIDs()
1434
                ' Collect all focusItems that are descendants of this group
1435
                ' Exclude items that belong to nested sub-groups
1436
                ' Also include direct child groups with enableSpatialNavigation: true
1437
                focusItems = m.focusItemsRef.getAll()
1✔
1438
                groups = m.groupsRef.getAll()
1✔
1439
                HIDlen = Len(m.HID)
1✔
1440
                collection = []
1✔
1441
                groupsKeys = groups.keys()
1✔
1442
                groupsCount = groups.Count()
1✔
1443

1444
                ' Collect focusItems (existing logic)
1445
                for each focusItemHID in focusItems
1✔
1446
                    ' Check if focusItem is a descendant of this group
1447
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
1✔
1448
                    if isDescendant
1449
                        ' Check if focusItem belongs to a nested sub-group
1450
                        shouldExclude = false
1✔
1451
                        otherGroupIndex = 0
1✔
1452
                        while shouldExclude = false and otherGroupIndex < groupsCount
1✔
1453
                            otherGroupHID = groupsKeys[otherGroupIndex]
1✔
1454
                            otherGroupHIDlen = Len(otherGroupHID)
1✔
1455
                            ' Exclude if belongs to deeper nested group
1456
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
1✔
1457
                            otherGroupIndex++
1✔
1458
                        end while
1459

1460
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1461
                    end if
1462
                end for
1463

1464
                ' Collect direct child groups with enableSpatialNavigation: true
1465
                for i = 0 to groupsCount - 1
1✔
1466
                    childGroupHID = groupsKeys[i]
1✔
1467
                    childGroupHIDlen = Len(childGroupHID)
1✔
1468

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

1473
                        ' Only include if enableSpatialNavigation is true
1474
                        if childGroup.enableSpatialNavigation = true
2✔
1475
                            ' Check if it's a DIRECT child (no intermediate groups)
1476
                            isDirectChild = true
×
1477
                            for j = 0 to groupsCount - 1
×
1478
                                intermediateHID = groupsKeys[j]
×
1479
                                intermediateLen = Len(intermediateHID)
×
1480
                                ' Check if there's a group between this group and the child
1481
                                if intermediateLen > HIDlen and intermediateLen < childGroupHIDlen
×
1482
                                    if Left(childGroupHID, intermediateLen) = intermediateHID
×
UNCOV
1483
                                        isDirectChild = false
×
1484
                                        exit for
1485
                                    end if
1486
                                end if
1487
                            end for
1488

1489
                            if isDirectChild then collection.push(childGroupHID)
×
1490
                        end if
1491
                    end if
1492
                end for
1493

1494
                return collection
1✔
1495
            end function
1496

1497
            '
1498
            ' getFallbackNodeId - Returns the nodeId to use for fallback (defaultFocusId or lastFocusedHID)
1499
            '
1500
            ' @returns {string} The nodeId to use for fallback, or empty string if none
1501
            '
1502
            function getFallbackNodeId() as string
1503
                if m.lastFocusedHID <> ""
2✔
1504
                    ' Note: lastFocusedHID is already a HID, not a nodeId, so we need to get the nodeId
1505
                    lastFocusedItem = m.focusItemsRef.get(m.lastFocusedHID)
×
1506
                    if lastFocusedItem <> invalid
×
1507
                        return lastFocusedItem.id
×
1508
                    end if
1509
                end if
1510

1511
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1512
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1513
                else
3✔
1514
                    return m.defaultFocusId
1✔
1515
                end if
1516
            end function
1517

1518
            function getFallbackIdentifier(globalFocusHID = "" as string, direction = "" as string) as string
1519
                HID = ""
1✔
1520
                ' enableSpatialEnter is handled by capturingFocus_recursively using spatialNavigation
1521
                ' Here we only handle lastFocusedHID and defaultFocusId fallbacks
1522

1523
                ' Use lastFocusedHID if available AND still exists (check both focusItems and groups)
1524
                if not m.isSpatialEnterEnabledForDirection(direction) and m.lastFocusedHID <> ""
2✔
1525
                    if m.focusItemsRef.has(m.lastFocusedHID) or m.groupsRef.has(m.lastFocusedHID)
3✔
1526
                        return m.lastFocusedHID
1✔
1527
                    end if
1528
                end if
1529

1530
                ' Default: use defaultFocusId expression
1531
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1532
                    defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
1✔
1533
                else
3✔
1534
                    defaultFocusId = m.defaultFocusId
1✔
1535
                end if
1536

1537
                if defaultFocusId <> ""
3✔
1538
                    focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1539
                    if focusItemsHIDlist.Count() > 0
3✔
1540
                        ' Try find valid HID in focusItems by node id
1541
                        focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1✔
1542
                        if focusItemHID <> ""
3✔
1543
                            return focusItemHID
1✔
1544
                        end if
1545
                    end if
1546
                    ' If not found as focusItem, return defaultFocusId string
1547
                    ' so capturingFocus_recursively can try to resolve it as a group
1548
                    return defaultFocusId
1✔
1549
                end if
1550

1551
                return HID
×
1552
            end function
1553

1554
            function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1555
                for each itemHID in focusItemsHIDlist
1✔
1556
                    focusItem = m.focusItemsRef.get(itemHID)
1✔
1557
                    if focusItem <> invalid and focusItem.id = nodeId
3✔
1558
                        return focusItem.HID
1✔
1559
                    end if
1560
                end for
1561
                return ""
×
1562
            end function
1563

1564
            sub applyFocus(isFocused as boolean)
1565
                if m.isFocused = isFocused then return
2✔
1566

1567
                m.isFocused = isFocused
1✔
1568

1569
                if m.autoSetIsFocusedState
3✔
1570
                    m.widget.viewModelState.isFocused = isFocused
1✔
1571
                end if
1572
                m.node.setField("isFocused", isFocused)
1✔
1573
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1574
            end sub
1575

1576
            override sub destroy()
1577
                super.destroy()
1✔
1578
                m.focusItemsRef = invalid
1✔
1579
                m.groupsRef = invalid
1✔
1580
            end sub
1581

1582

1583

1584
        end class
1585

1586
        class FocusItemClass extends BaseFocusConfig
1587

1588
            sub new (config as object)
1589
                super(config)
1✔
1590

1591
                m.onSelect = config.onSelect ?? ""
1✔
1592
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1593
            end sub
1594

1595
            ' You can set a groupId or any focusItem widgetId.
1596
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1597
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1598

1599
            ' key as string
1600
            isFocusItem = true
1601
            isGroup = false
1602
            enableNativeFocus as boolean
1603
            onSelect as dynamic
1604

1605
            private bounding as object
1606

1607

1608
            override function refreshBounding() as object
1609
                b = m.node.sceneBoundingRect()
1✔
1610
                rotation = m.node.rotation
1✔
1611

1612
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1613
                ' That is why we can use translation without knowing the value of inheritParentTransform
1614
                ' If bounding x or y are not zero, then bounding will include the node's translation
1615
                if rotation = 0
3✔
1616
                    if b.y = 0 and b.x = 0
2✔
1617
                        t = m.node.translation
1✔
1618
                        b.x += t[0]
1✔
1619
                        b.y += t[1]
1✔
1620
                    end if
1621

1622
                    m.metrics.append(b)
1✔
1623
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
1✔
1624
                        x1: b.x, y1: b.y,
1625
                        x2: b.x, y2: b.y + b.height
1626
                    }
1627
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
1✔
1628
                        x1: b.x, y1: b.y,
1629
                        x2: b.x + b.width, y2: b.y
1630
                    }
1631
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
1✔
1632
                        x1: b.x + b.width, y1: b.y,
1633
                        x2: b.x + b.width, y2: b.y + b.height
1634
                    }
1635
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
1✔
1636
                        x1: b.x, y1: b.y + b.height,
1637
                        x2: b.x + b.width, y2: b.y + b.height
1638
                    }
1639
                    m.metrics.middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
1✔
1640
                else
×
1641
                    scaleRotateCenter = m.node.scaleRotateCenter
×
1642
                    dims = m.node.localBoundingRect() ' We need this to get proper (rotated value of rotated x and y)
×
1643
                    if b.y = 0 and b.x = 0
×
1644
                        t = m.node.translation
×
1645
                        b.x += t[0]
×
1646
                        b.y += t[1]
×
1647
                    end if
1648
                    b.width = dims.width
×
1649
                    b.height = dims.height
×
1650
                    m.metrics.append(b)
×
1651

1652
                    ' Calculate rotated segments
1653
                    segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
×
1654
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1655
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
×
1656

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

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

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

1669
                    ' Calculate rotated middle point
1670
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1671
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1672
                    m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
×
1673

1674
                end if
1675

1676
                return m.metrics
1✔
1677
            end function
1678

1679
            override sub destroy()
1680
                m.onSelect = invalid
1✔
1681
                m.metrics.segments.Clear()
1✔
1682
                super.destroy()
1✔
1683
            end sub
1684

1685
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1686
                if m.isFocused = isFocused then return
2✔
1687

1688
                m.isFocused = isFocused
1✔
1689

1690
                if m.autoSetIsFocusedState
3✔
1691
                    m.widget.viewModelState.isFocused = isFocused
1✔
1692
                end if
1693

1694
                m.node.setField("isFocused", isFocused)
1✔
1695

1696
                if enableNativeFocus or m.enableNativeFocus
2✔
1697
                    m.node.setFocus(isFocused)
×
1698
                end if
1699

1700
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1701

1702
            end sub
1703

1704
            sub callOnSelectFnOnWidget()
1705
                Rotor.Utils.callbackScoped(m.onSelect, m.widget)
1✔
1706
            end sub
1707

1708
        end class
1709

1710
        class ClosestSegmentToPointCalculatorClass
1711

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

1715
                A = x - x1
1✔
1716
                B = y - y1
1717
                C = x2 - x1
1✔
1718
                D = y2 - y1
1✔
1719

1720
                dot = A * C + B * D
1✔
1721
                len_sq = C * C + D * D
1✔
1722
                param = -1
1✔
1723
                if len_sq <> 0
3✔
1724
                    param = dot / len_sq
1✔
1725
                end if
1726

1727
                xx = 0
1✔
1728
                yy = 0
1✔
1729

1730
                if param < 0
2✔
1731
                    xx = x1
1✔
1732
                    yy = y1
1✔
1733
                else if param > 1
3✔
1734
                    xx = x2
1✔
1735
                    yy = y2
1✔
1736
                else
3✔
1737
                    xx = x1 + param * C
1✔
1738
                    yy = y1 + param * D
1✔
1739
                end if
1740

1741
                dx = x - xx
1✔
1742
                dy = y - yy
1✔
1743
                return dx * dx + dy * dy
1✔
1744
            end function
1745

1746
            function distToSegment(p as object, s1 as object, s2 as object)
1747
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1748
            end function
1749

1750
        end class
1751

1752
    end namespace
1753

1754
    namespace FocusPluginHelper
1755

1756
        sub longPressObserverCallback(msg)
1757
            extraInfo = msg.GetInfo()
1✔
1758

1759
            pluginKey = extraInfo["pluginKey"]
1✔
1760

1761
            globalScope = GetGlobalAA()
1✔
1762
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
1✔
1763
            plugin = frameworkInstance.plugins[pluginKey]
1✔
1764
            plugin.isLongPress = true
1✔
1765
            ' plugin.longPressStartHID = plugin.globalFocusHID
1766
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
1✔
1767

1768
        end sub
1769

1770
    end namespace
1771

1772
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