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

mobalazs / rotor-framework / 21416879966

27 Jan 2026 10:34PM UTC coverage: 86.72% (+0.2%) from 86.568%
21416879966

push

github

web-flow
Feat/focus item nodeid fix (#21)

* fix(FocusPlugin): handle nodeId clearing for navigation attempts

* fix(FocusPlugin): enhance spatial navigation

* test(FocusPluginTest): add tests for enableSpatialEnter behavior

35 of 36 new or added lines in 1 file covered. (97.22%)

1 existing line in 1 file now uncovered.

2057 of 2372 relevant lines covered (86.72%)

1.2 hits per line

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

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

4
namespace Rotor
5

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

174
    const PRIMARY_FOCUS_PLUGIN_KEY = "focus"
175
    const GROUP_FOCUS_PLUGIN_KEY = "focusGroup"
176
    class FocusPlugin extends Rotor.BasePlugin
177

178
        pluginKey = PRIMARY_FOCUS_PLUGIN_KEY
179
        aliasPluginKey = GROUP_FOCUS_PLUGIN_KEY
180

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

196
                if isFocusItem and isFocusGroup
2✔
197
                    #if debug
×
198
                        ? "[FOCUS_PLUGIN][ERROR] Widget '" + widget.id + "' (HID: " + widget.HID + ") cannot have both 'focus' and 'focusGroup' configurations!"
×
199
                    #end if
200
                    return ' Skip setup for this widget
×
201
                end if
202

203
                config = widget[isFocusItem ? PRIMARY_FOCUS_PLUGIN_KEY : GROUP_FOCUS_PLUGIN_KEY]
1✔
204
                scope.setFocusConfig(widget, config)
1✔
205
            end sub,
206

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

221
                ' Determine whether this widget is a focus item or focus group
222
                targetKey = PRIMARY_FOCUS_PLUGIN_KEY
×
223
                if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY) and widget[PRIMARY_FOCUS_PLUGIN_KEY] <> invalid
×
224
                    targetKey = PRIMARY_FOCUS_PLUGIN_KEY
×
225
                else
×
226
                    targetKey = GROUP_FOCUS_PLUGIN_KEY
×
227
                end if
228

229
                ' Ensure target config exists
230
                if not Rotor.Utils.isAssociativeArray(widget[targetKey])
×
231
                    widget[targetKey] = {}
×
232
                end if
233

234
                ' Merge new config into existing widget config (or replace if non-AA)
235
                if Rotor.Utils.isAssociativeArray(newValue)
×
236
                    Rotor.Utils.deepExtendAA(widget[targetKey], newValue)
×
237
                else
×
238
                    widget[targetKey] = newValue
×
239
                end if
240

241
                scope.setFocusConfig(widget, widget[targetKey])
×
242
            end sub,
243

244
            ' ---------------------------------------------------------------------
245
            ' beforeDestroy - Hook executed before a widget is destroyed
246
            '
247
            ' Removes focus config.
248
            '
249
            ' @param {object} scope - The plugin scope (this instance)
250
            ' @param {object} widget - The widget being destroyed
251
            '
252
            beforeDestroy: sub(scope as object, widget as object)
253
                scope.removeFocusConfig(widget.HID)
1✔
254
            end sub
255
        }
256

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

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

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

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

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

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

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

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

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

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

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

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

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

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

384
        }
385

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

391
        ' State tracking
392
        globalFocusHID = ""
393
        globalFocusId = ""
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
        ' ---------------------------------------------------------------------
408
        ' init - Initializes the plugin instance
409
        '
410
        ' Sets up internal state and helpers.
411
        '
412
        sub init ()
413
            m.widgetTree = m.frameworkInstance.builder.widgetTree ' Reference to the main widget tree
1✔
414
            m.longPressTimer.addField("pluginKey", "string", false)
1✔
415
            m.longPressTimer.setFields({
1✔
416
                "pluginKey": m.pluginKey,
417
                duration: m.longPressDuration
418
            })
419
            ' Observe timer fire event to handle long press callback
420
            m.longPressTimer.observeFieldScoped("fire", "Rotor_FocusPluginHelper_longPressObserverCallback", ["pluginKey"])
1✔
421
        end sub
422

423
        '
424
        ' storeGlobalFocusHID - Stores the globally focused widget's HID and ID
425
        '
426
        ' @param {string} HID - The Hierarchical ID of the focused widget
427
        ' @param {string} id - The regular ID of the focused widget
428
        '
429
        sub storeGlobalFocusHID(HID as string, id as string)
430
            ' Store focus reference within the plugin
431
            m.globalFocusHID = HID
1✔
432
            m.globalFocusId = id
1✔
433
        end sub
434

435
        '
436
        ' getFocusedWidget - Gets the widget instance that currently holds global focus
437
        '
438
        ' @returns {object} The focused widget object, or invalid if none
439
        '
440
        function getFocusedWidget() as object
441
            return m.getFocusedItem()?.widget
1✔
442
        end function
443

444
        '
445
        ' getFocusedItem - Gets the FocusItem instance corresponding to the globally focused widget
446
        '
447
        ' @returns {object} The FocusItem instance, or invalid if none
448
        '
449
        function getFocusedItem() as object
450
            return m.focusItemStack.get(m.globalFocusHID)
1✔
451
        end function
452

453
        '
454
        ' setFocusConfig - Configures focus properties (FocusItem and/or Group) for a widget
455
        '
456
        ' @param {object} widget - The widget to configure
457
        ' @param {object} pluginConfig - The focus configuration object from the widget's spec
458
        '
459
        sub setFocusConfig(widget as object, pluginConfig as object)
460

461
            if pluginConfig = invalid then return ' No config provided
2✔
462
            HID = widget.HID
1✔
463
            id = widget.id
1✔
464

465
            ' Make a copy to avoid modifying the original config
466
            config = Rotor.Utils.deepCopy(pluginConfig)
1✔
467

468
            ' Ensure essential identifiers are in the config
469
            config.id = id
1✔
470
            config.HID = widget.HID
1✔
471

472
            ' Handle group configuration if present
473
            if widget.DoesExist(PRIMARY_FOCUS_PLUGIN_KEY)
3✔
474
                ' Handle focus item configuration if applicable
475
                m.setupFocusItem(HID, config, widget)
1✔
476
            else
477
                ' Handle group configuration
478
                m.setupGroup(HID, config, widget)
1✔
479
            end if
480
        end sub
481

482
        '
483
        ' setupGroup - Creates and registers a new Focus Group based on configuration
484
        '
485
        ' @param {string} HID - The Hierarchical ID of the widget acting as the group root
486
        ' @param {object} config - The full focus configuration for the widget
487
        ' @param {object} widget - The widget instance itself
488
        '
489
        sub setupGroup(HID as string, config as object, widget as object)
490
            ' Copy essential info to the group-specific config
491
            config.id = config.id
1✔
492
            config.HID = config.HID
1✔
493
            config.widget = widget
1✔
494
            ' Create and configure the Group instance
495
            newGroup = new Rotor.FocusPluginHelper.GroupClass(config)
1✔
496
            newGroup.focusItemsRef = m.focusItemStack ' Provide reference to focus items
1✔
497
            newGroup.groupsRef = m.groupStack ' Provide reference to other groups
1✔
498
            m.groupStack.set(config.HID, newGroup) ' Register the new group
1✔
499
        end sub
500

501
        '
502
        ' setupFocusItem - Creates and registers a new Focus Item based on configuration
503
        '
504
        ' @param {string} HID - The Hierarchical ID of the focusItem widget
505
        ' @param {object} config - The full focus configuration for the widget
506
        ' @param {object} widget - The widget instance itself
507
        '
508
        sub setupFocusItem(HID as string, config as object, widget as object)
509
            config.widget = widget ' Ensure widget reference is in the config
1✔
510

511
            ' Create and register the FocusItem instance
512
            newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
1✔
513
            m.focusItemStack.set(HID, newFocusItem)
1✔
514
        end sub
515

516
        '
517
        ' findAncestorGroups - Finds all ancestor groups for a given widget HID
518
        '
519
        ' @param {string} HID - The Hierarchical ID of the widget
520
        ' @returns {object} An roArray of ancestor group HIDs, sorted with the immediate parent first (descending HID length)
521
        '
522
        function findAncestorGroups(HID as string) as object
523
            allGroups = m.groupStack.getAll() ' Get all registered groups
1✔
524
            ancestorGroups = []
1✔
525
            ' Iterate through all groups to find ancestors
526
            for each groupHID in allGroups
1✔
527
                if Rotor.Utils.isAncestorHID(groupHID, HID)
2✔
528
                    ancestorGroups.push(groupHID)
1✔
529
                end if
530
            end for
531
            ' Sort by HID length descending (parent first)
532
            ancestorGroups.Sort("r")
1✔
533

534
            ' Note:
535
            ' - Parent group is at index 0
536
            ' - If HID is a focusItem, its direct parent group is included
537
            ' - If HID is a group, the group itself is NOT included
538
            return ancestorGroups
1✔
539
        end function
540

541
        '
542
        ' removeFocusConfig - Removes focus configuration (Group and/or FocusItem) for a widget
543
        '
544
        ' @param {string} HID - The Hierarchical ID of the widget whose config should be removed
545
        '
546
        sub removeFocusConfig(HID as string)
547
            ' Remove associated group, if it exists
548
            if m.groupStack.has(HID)
2✔
549
                m.groupStack.remove(HID)
1✔
550
            end if
551
            ' Remove associated focus item, if it exists
552
            if m.focusItemStack.has(HID)
2✔
553
                m.focusItemStack.remove(HID)
1✔
554
            end if
555
        end sub
556

557
        '
558
        ' setFocus - Sets or removes focus from a specific widget or group
559
        '
560
        ' Handles focus state changes, callbacks, and native focus interaction.
561
        '
562
        ' @param {dynamic} ref - The target: HID (string) of a FocusItem or Group, or Node ID (string) of a Group
563
        ' @param {boolean} isFocused - True to set focus, false to remove focus (default: true)
564
        ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node (default: false)
565
        ' @returns {boolean} True if the focus state was successfully changed, false otherwise
566
        '
567
        function setFocus(ref as dynamic, isFocused = true as boolean, enableNativeFocus = false as boolean) as boolean
568

569
            ' Resolve reference (HID or ID) to a focusItem item.
570
            focusItem = invalid ' Initialize target focus item
1✔
571

572
            ' Exit if reference is empty or invalid.
573
            if ref = invalid or ref = "" then return false
2✔
574

575
            if m.focusItemStack.has(ref)
2✔
576
                ' Case 1: ref is a valid focusItem HID.
577
                focusItem = m.focusItemStack.get(ref)
1✔
578
            else
579
                ' Case 2: ref might be a focusItem node ID.
3✔
580
                focusItem = m.focusItemStack.getByNodeId(ref)
1✔
581

582
                if focusItem = invalid
3✔
583
                    ' Case 3: ref might be a group HID or group node ID.
584
                    ' Try finding group by HID first, then by Node ID.
585
                    group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
1✔
586
                    if group <> invalid
3✔
587
                        ' If group found, find its default/entry focus item recursively.
588
                        HID = m.capturingFocus_recursively(group.HID)
1✔
589
                        focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
1✔
590
                        ' else: ref is not a known FocusItem HID or Group identifier
591
                    end if
592
                end if
593
            end if
594

595
            ' Handle case where the target focus item could not be found or resolved.
596
            if focusItem = invalid
2✔
597
                focused = m.focusItemStack.get(m.globalFocusHID) ' Check current focus
1✔
598
                #if debug
4✔
599
                    ' Log warnings if focus target is not found
600
                    if focused = invalid
2✔
601
                        print `[PLUGIN][FOCUS][WARNING] Requested focus target ref: "${ref}" was not found or resolved to a valid FocusItem.`
×
602
                        if m.globalFocusHID = ""
×
603
                            ' If global focus is also lost, indicate potential issue.
604
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue likely. No current focus set. Ensure valid initial focus.`
×
605
                        else
×
606
                            print `[PLUGIN][FOCUS][WARNING] Current focus HID: "${m.globalFocusHID}". Ensure target "${ref}" is registered and reachable.`
×
607
                        end if
608
                    else
3✔
609
                        print `[PLUGIN][FOCUS][WARNING] Could not find focus target ref: "${ref}". Current focus remains on HID: "${m.globalFocusHID}", id"${m.globalFocusId}"".`
1✔
610
                    end if
611
                #end if
612
                return false ' Indicate focus change failed
1✔
613
            end if
614

615
            ' Found a valid focusItem to target
616
            HID = focusItem.HID
1✔
617

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

622
            ' Cannot focus an invisible item.
623
            if focusItem.node.visible = false and isFocused = true then return false
2✔
624

625
            ' Determine if native focus should be enabled (request or item default)
626
            enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
1✔
627

628
            ' Prevent focusing a disabled item.
629
            preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
1✔
630
            if preventFocusOnDisabled
2✔
631
                return false ' Indicate focus change failed
×
632
            end if
633

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

637
            lastFocusChainingGroups = []
1✔
638

639
            ' Handle blurring the previously focused item
640
            if m.globalFocusHID <> "" ' If something was focused before
2✔
641
                lastFocused = m.focusItemStack.get(m.globalFocusHID)
1✔
642
                if lastFocused <> invalid ' Check if the last focused widget hasn't been destroyed
3✔
643
                    ' Record the last focused item within its parent group for potential future use (e.g., returning focus)
644
                    lastFocusChainingGroups = m.findAncestorGroups(m.globalFocusHID)
1✔
645
                    if lastFocusChainingGroups.Count() > 0
3✔
646
                        ' Always set on immediate parent (index 0)
647
                        parentGroupHID = lastFocusChainingGroups[0]
1✔
648
                        if parentGroupHID <> invalid and parentGroupHID <> ""
3✔
649
                            group = m.groupStack.get(parentGroupHID)
1✔
650
                            if group <> invalid
3✔
651
                                group.setLastFocusedHID(m.globalFocusHID)
1✔
652
                            end if
653
                        end if
654
                    end if
655
                end if
656
            end if
657

658
            ' Prepare notification list: all affected groups (unique)
659
            allAffectedGroups = []
1✔
660
            for each groupHID in focusChainGroups
1✔
661
                allAffectedGroups.unshift(groupHID) ' Add in reverse order (highest ancestor first)
1✔
662
            end for
663
            for i = 0 to lastFocusChainingGroups.Count() - 1
1✔
664
                groupHID = lastFocusChainingGroups[i]
1✔
665

666
                ' Add to allAffectedGroups if not present
667
                if -1 = Rotor.Utils.findInArray(allAffectedGroups, groupHID)
2✔
668
                    allAffectedGroups.unshift(groupHID)
1✔
669
                end if
670

671
                ' Deep remember (skip index 0 - immediate parent is handled separately)
672
                if i > 0 and lastFocused <> invalid
3✔
673
                    ancestorGroup = m.groupStack.get(groupHID)
1✔
674
                    if ancestorGroup <> invalid and ancestorGroup.trackDescendantFocus = true
2✔
675
                        ancestorGroup.setLastFocusedHID(m.globalFocusHID)
1✔
676
                    end if
677
                end if
678
            end for
679

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

683
            ' Blur the previously focused item (after notification)
684
            if m.globalFocusHID <> "" and lastFocused <> invalid
2✔
685
                lastFocused.applyFocus(false, enableNativeFocus)
1✔
686
            end if
687

688
            ' Apply focus state (focused/blurred) to the target item.
689
            focusItem.applyFocus(isFocused, enableNativeFocus)
1✔
690

691
            ' Update the globally tracked focused item.
692
            m.storeGlobalFocusHID(isFocused ? HID : "", isFocused ? focusItem.id : "")
1✔
693

694
            ' Ensure SceneGraph root has focus if native focus wasn't explicitly enabled on the item.
695
            if enableNativeFocus = false
3✔
696
                globalScope = GetGlobalAA()
1✔
697
                if globalScope.top.isInFocusChain() = false
2✔
698
                    globalScope.top.setFocus(true)
1✔
699
                end if
700
            end if
701

702
            return true
1✔
703

704
        end function
705

706
        '
707
        ' notifyFocusAtAncestorGroups - Applies the correct focus state (in focus chain or not) to a list of group HIDs
708
        '
709
        ' @param {string} HID - The HID of the item that ultimately received/lost focus
710
        ' @param {object} groupHIDs - An roArray of group HIDs to notify
711
        '
712
        sub notifyFocusAtAncestorGroups(HID as string, groupHIDs = [] as object)
713

714
            ' Notify all ancestor groups
715
            if groupHIDs.Count() > 0
3✔
716
                for each groupHID in groupHIDs
1✔
717

718
                    group = m.groupStack.get(groupHID)
1✔
719
                    isInFocusChain = Rotor.Utils.isAncestorHID(groupHID, HID)
1✔
720
                    group.applyFocus(isInFocusChain)
1✔
721

722
                end for
723
            end if
724
        end sub
725

726
        sub notifyLongPressAtAncestorGroups(isLongPress as boolean, key as string, HID as string, groupHIDs = [] as object)
727
            ' Notify all ancestor groups
728
            if groupHIDs.Count() > 0
3✔
729
                for each groupHID in groupHIDs
1✔
730
                    group = m.groupStack.get(groupHID)
1✔
731
                    handled = group.callLongPressHandler(isLongPress, key)
1✔
732
                    if handled then exit for
2✔
733
                end for
734
            end if
735
        end sub
736

737
        sub delegateLongPressChanged(isLongPress as boolean, key as string)
738
            focused = m.getFocusedItem()
1✔
739
            handled = focused.callLongPressHandler(isLongPress, key)
1✔
740
            if handled then return
2✔
741

742
            focusChainGroups = m.findAncestorGroups(focused.HID)
1✔
743
            m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
1✔
744
        end sub
745

746
        function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object) as string
747
            if focused.enableSpatialNavigation = false then return ""
2✔
748
            if direction = Rotor.Const.Direction.BACK then return ""
2✔
749

750
            ' Remove current focused item from candidates
751
            index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
1✔
752
            if index >= 0 then focusItemsHIDlist.delete(index)
1✔
753

754
            ' Find closest focusable item in direction
755
            segments = m.collectSegments(focused, direction, focusItemsHIDlist)
1✔
756
            if segments.Count() > 0
2✔
757
                return m.findClosestSegment(segments, focused.metrics.middlePoint)
1✔
758
            end if
759

760
            return ""
1✔
761
        end function
762

763
        function findClosestSegment(segments as object, middlePoint as object) as string
764
            distances = []
1✔
765

766
            ' Calculate distance from middle point to each segment
767
            for each HID in segments
1✔
768
                segment = segments[HID]
1✔
769
                distance = m.distanceCalculator.distToSegment(middlePoint, {
1✔
770
                    x: segment.x1,
771
                    y: segment.y1
772
                }, {
773
                    x: segment.x2,
774
                    y: segment.y2
775
                })
776

777
                distances.push({
1✔
778
                    HID: HID,
779
                    distance: distance
780
                })
781
            end for
782

783
            ' Find segment with minimum distance
784
            minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
1✔
785
                return a < b
786
            end function)
787

788
            return minDistItem.HID
1✔
789
        end function
790

791

792
        ' Waterfall of fallback's of groups (linked together with defaultFocusId)
793
        function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
794
            ' Resolve identifier to a group
795
            group = m.groupStack.get(identifier)
1✔
796
            if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
1✔
797
            if group = invalid then return ""
2✔
798

799
            ' Get fallback identifier for this group
800
            ' (enableSpatialEnter groups return the spatially closest member here)
801
            newHID = group.getFallbackIdentifier(m.globalFocusHID)
1✔
802

803
            ' Check if we found a FocusItem
804
            if m.focusItemStack.has(newHID)
3✔
805
                ' noop — direct focusItem resolved
806
            else if newHID <> ""
3✔
807
                ' Try to find as group first, then deep search
808
                newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
1✔
809

810
                ' If still not found, perform deep search in all descendants
811
                if newHID = ""
2✔
812
                    newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
1✔
813
                end if
814
            end if
815

816
            ' Prevent capturing by fallback in the same group where original focus was
817
            ' Skip this guard for enableSpatialEnter groups (spatial enter explicitly targets a sibling group's member)
818
            if not group.enableSpatialEnter and newHID <> "" and m.globalFocusHID <> ""
2✔
819
                currentAncestors = m.findAncestorGroups(m.globalFocusHID)
1✔
820
                newAncestors = m.findAncestorGroups(newHID)
1✔
821
                if currentAncestors.Count() > 0 and newAncestors.Count() > 0
3✔
822
                    if currentAncestors[0] = newAncestors[0] then newHID = ""
2✔
823
                end if
824
            end if
825

826
            return newHID
1✔
827
        end function
828

829
        '
830
        ' deepSearchFocusItemByNodeId - Deep search for a FocusItem or Group by nodeId within a group hierarchy
831
        '
832
        ' @param {string} groupHID - The HID of the group to search within
833
        ' @param {string} nodeId - The node ID to search for
834
        ' @returns {string} The HID of the found FocusItem or Group, or empty string if not found
835
        '
836
        function deepSearchFocusItemByNodeId(groupHID as string, nodeId as string) as string
837
            if nodeId = "" then return ""
2✔
838

839
            ' Get all descendants of this group (both FocusItems and nested Groups)
840
            allFocusItems = m.focusItemStack.getAll()
1✔
841
            allGroups = m.groupStack.getAll()
1✔
842

843
            ' First, search in direct and nested FocusItems
844
            for each focusItemHID in allFocusItems
1✔
845
                if Rotor.Utils.isDescendantHID(focusItemHID, groupHID)
3✔
846
                    focusItem = m.focusItemStack.get(focusItemHID)
1✔
847
                    if focusItem <> invalid and focusItem.id = nodeId
2✔
848
                        return focusItemHID
×
849
                    end if
850
                end if
851
            end for
852

853
            ' Second, search in nested Groups (and if found, apply fallback logic on that group)
854
            for each nestedGroupHID in allGroups
1✔
855
                if Rotor.Utils.isDescendantHID(nestedGroupHID, groupHID) and nestedGroupHID <> groupHID
3✔
856
                    nestedGroup = m.groupStack.get(nestedGroupHID)
1✔
857
                    if nestedGroup <> invalid and nestedGroup.id = nodeId
3✔
858
                        ' Found a matching group - now apply fallback logic on it
859
                        fallbackHID = nestedGroup.getFallbackIdentifier()
1✔
860
                        if m.focusItemStack.has(fallbackHID)
3✔
861
                            return fallbackHID
1✔
862
                        else if fallbackHID <> ""
×
863
                            ' Recursively resolve the fallback
864
                            return m.capturingFocus_recursively(fallbackHID, "", nestedGroupHID)
×
865
                        end if
866
                    end if
867
                end if
868
            end for
869

870
            return ""
×
871
        end function
872

873
        function bubblingFocus(groupHID, direction = "" as string) as dynamic
874
            newHID = ""
1✔
875

876
            ' Build ancestor chain (current group + all ancestors)
877
            ancestorGroups = m.findAncestorGroups(groupHID)
1✔
878
            ancestorGroups.unshift(groupHID)
1✔
879
            ancestorGroupsCount = ancestorGroups.Count()
1✔
880
            ancestorIndex = 0
1✔
881

882
            ' Bubble up through ancestor groups until we find a target or reach the top
883
            while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
1✔
884
                ' Get next ancestor group
885
                groupHID = ancestorGroups[ancestorIndex]
1✔
886
                group = m.groupStack.get(groupHID)
1✔
887

888
                ' Check group's direction configuration
889
                nodeId = group.getStaticNodeIdInDirection(direction)
1✔
890

891
                if Rotor.Utils.isBoolean(nodeId)
2✔
892
                    ' Boolean means focus is explicitly handled
893
                    if nodeId = true
3✔
894
                        newHID = true ' Block navigation (exit loop)
1✔
895
                    else
×
896
                        newHID = "" ' Continue bubbling
×
897
                    end if
898
                else
899
                    ' String nodeId - try to resolve target
3✔
900
                    if nodeId <> ""
3✔
901
                        otherGroup = m.groupStack.getByNodeId(nodeId)
1✔
902
                        if otherGroup <> invalid
3✔
903
                            newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
1✔
904
                        end if
905
                    end if
906
                end if
907

908
                ancestorIndex++
1✔
909
            end while
910

911
            return newHID
1✔
912
        end function
913

914
        ' * KEY EVENT HANDLER
915
        function onKeyEventHandler(key as string, press as boolean) as object
916
            ' Check long-press
917
            if m.enableLongPressFeature = true
3✔
918
                m.checkLongPressState(key, press)
1✔
919
            end if
920
            ' Prevent any navigation if it is disabled
921
            #if debug
4✔
922
            #end if
923
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
2✔
924
            ' Execute action according to key press
925
            return m.executeNavigationAction(key, press)
1✔
926
        end function
927

928
        function executeNavigationAction(key as string, press as boolean) as object
929

930
            if true = press
3✔
931

932
                if -1 < Rotor.Utils.findInArray([
3✔
933
                        Rotor.Const.Direction.UP,
934
                        Rotor.Const.Direction.RIGHT,
935
                        Rotor.Const.Direction.DOWN,
936
                        Rotor.Const.Direction.LEFT,
937
                        Rotor.Const.Direction.BACK
938
                    ], key)
939

940
                    newHID = ""
1✔
941
                    direction = key
1✔
942

943
                    ' (1) Pick up current focused item
944

945
                    focused = m.focusItemStack.get(m.globalFocusHID)
1✔
946

947
                    if focused = invalid
948
                        #if debug
×
949
                            print `[PLUGIN][FOCUS][WARNING] Focus lost issue detected. Last known focus id:\"${m.globalFocusHID}\". Please ensure valid focus.`
×
950
                        #end if
951
                        return m.parseOnKeyEventResult(key, false, false)
×
952
                    end if
953

954

955
                    ancestorGroups = m.findAncestorGroups(focused.HID)
1✔
956
                    ancestorGroupsCount = ancestorGroups.Count()
1✔
957

958
                    if ancestorGroupsCount = 0
2✔
959
                        allFocusItems = m.focusItemStack.getAll()
×
960
                        possibleFocusItems = allFocusItems.keys()
×
961
                        parentGroupHID = ""
×
962
                    else
3✔
963
                        parentGroupHID = ancestorGroups[0]
1✔
964
                        group = m.groupStack.get(parentGroupHID)
1✔
965
                        possibleFocusItems = group.getGroupMembersHIDs()
1✔
966
                    end if
967

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

971
                    if Rotor.Utils.isBoolean(nodeId) and nodeId = true
2✔
972
                        ' It means that focus is handled, and no need further action by plugin.
973
                        return m.parseOnKeyEventResult(key, true, false)
×
974
                    end if
975

976
                    if nodeId <> ""
2✔
977
                        newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
×
978
                    end if
979

980
                    if newHID = ""
3✔
981
                        ' (3) Try spatial navigation in direction, among possible focusItems
982
                        ' all = m.focusItemStack.getAll()
983
                        ' allKeys = all.Keys()
984
                        newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
1✔
985
                    end if
986

987
                    ' (4) Check if found group. FocusItem can not point out of group.
988
                    if newHID = "" and ancestorGroupsCount > 0 ' (5/2) If this focused has parent group, lets try bubbling focus on ancestors (groups)
3✔
989
                        newHID = m.bubblingFocus(parentGroupHID, direction)
1✔
990
                        if Rotor.Utils.isBoolean(newHID)
2✔
991
                            if newHID = true
3✔
992
                                ' It means that focus is handled, and no need further action by plugin.
993
                                return m.parseOnKeyEventResult(key, true, false)
1✔
994
                            else
×
995
                                newHID = ""
×
996
                            end if
997
                        end if
998
                    end if
999

1000
                    handled = m.setFocus(newHID)
1✔
1001
                    return m.parseOnKeyEventResult(key, handled, false)
1✔
1002

1003
                else if key = "OK"
3✔
1004

1005
                    return m.parseOnKeyEventResult(key, true, true)
1✔
1006

1007
                end if
1008
            end if
1009

1010
            return m.parseOnKeyEventResult(key, false, false)
×
1011

1012
        end function
1013

1014
        function parseOnKeyEventResult(key as string, handled as boolean, isSelected as boolean) as object
1015
            result = {
1✔
1016
                handled: handled,
1017
                key: key
1018
            }
1019
            if m.globalFocusHID <> "" and handled = true
3✔
1020
                focusItem = m.focusItemStack.get(m.globalFocusHID)
1✔
1021
                widget = m.widgetTree.get(focusItem.HID)
1✔
1022
                ' viewModelState = Rotor.Utils.deepCopy(widget.viewModelState)
1023
                result.widget = widget
1✔
1024
                if isSelected
2✔
1025
                    result.isSelected = isSelected
1✔
1026
                    focusItem.callOnSelectFnOnWidget()
1✔
1027
                end if
1028
            end if
1029
            return result
1✔
1030
        end function
1031

1032
        sub checkLongPressState(key as string, press as boolean)
1033
            m.longPressTimer.control = "stop"
1✔
1034
            if press = true
3✔
1035
                if m.isLongPress = false
3✔
1036
                    m.longPressKey = key
1✔
1037
                    m.longPressTimer.control = "start"
1✔
1038
                end if
1039
            else
×
1040
                wasLongPress = m.isLongPress = true
×
1041
                lastKey = m.longPressKey
×
1042
                m.isLongPress = false
×
1043
                m.longPressKey = ""
×
1044
                if wasLongPress
×
1045
                    m.delegateLongPressChanged(false, lastKey)
×
1046
                end if
1047
            end if
1048
        end sub
1049

1050
        function proceedLongPress() as object
1051
            return m.executeNavigationAction(m.longPressKey, true)
×
1052
        end function
1053

1054
        ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
1055
        function collectSegments(focused as object, direction as string, focusItemsHIDlist as object) as object
1056
            focused.refreshBounding()
1✔
1057

1058
            refSegmentTop = focused.metrics.segments[Rotor.Const.Segment.TOP]
1✔
1059
            refSegmentRight = focused.metrics.segments[Rotor.Const.Segment.RIGHT]
1✔
1060
            refSegmentLeft = focused.metrics.segments[Rotor.Const.Segment.LEFT]
1✔
1061
            refSegmentBottom = focused.metrics.segments[Rotor.Const.Segment.BOTTOM]
1✔
1062
            referencePoint = { x: (refSegmentTop.x1 + refSegmentRight.x2) / 2, y: (refSegmentTop.y1 + refSegmentRight.y2) / 2 }
1✔
1063

1064
            validators = {
1✔
1065

1066
                "left": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
1067
                    right = segments[Rotor.Const.Segment.RIGHT]
1068
                    ' Candidate's right edge must be strictly left of focused element's left edge
1069
                    return right.x2 <= refSegmentLeft.x1 ? { isValid: true, segment: right } : { isValid: false }
1070
                end function,
1071

1072
                "up": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
1073
                    bottom = segments[Rotor.Const.Segment.BOTTOM]
1074
                    ' Candidate's bottom edge must be strictly above focused element's top edge
1075
                    return bottom.y2 <= refSegmentTop.y1 ? { isValid: true, segment: bottom } : { isValid: false }
1076
                end function,
1077

1078
                "right": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
1079
                    left = segments[Rotor.Const.Segment.LEFT]
1080
                    ' Candidate's left edge must be strictly right of focused element's right edge
1081
                    return left.x1 >= refSegmentRight.x2 ? { isValid: true, segment: left } : { isValid: false }
1082
                end function,
1083

1084
                "down": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
1085
                    top = segments[Rotor.Const.Segment.TOP]
1086
                    ' Candidate's top edge must be strictly below focused element's bottom edge
1087
                    return top.y1 >= refSegmentBottom.y2 ? { isValid: true, segment: top } : { isValid: false }
1088
                end function
1089
            }
1090
            segments = {}
1✔
1091
            validator = validators[direction]
1✔
1092
            for each HID in focusItemsHIDlist
1✔
1093
                if HID <> focused.HID
3✔
1094
                    focusItem = m.focusItemStack.get(HID)
1✔
1095
                    focusItem.refreshBounding()
1✔
1096
                    ' Pass appropriate reference segments based on direction
1097
                    if direction = "left" or direction = "right"
3✔
1098
                        result = validator(referencePoint, focusItem.metrics.segments, refSegmentLeft, refSegmentRight)
1✔
1099
                    else ' up or down
3✔
1100
                        result = validator(referencePoint, focusItem.metrics.segments, refSegmentTop, refSegmentBottom)
1✔
1101
                    end if
1102
                    if result.isValid
2✔
1103
                        segments[HID] = result.segment
1✔
1104
                    end if
1105
                end if
1106
            end for
1107

1108
            return segments
1✔
1109
        end function
1110

1111
        sub destroy()
1112
            ' Remove all groups
1113
            for each HID in m.groupStack.getAll()
1✔
1114
                m.groupStack.remove(HID)
1✔
1115
            end for
1116
            ' Remove all focus items
1117
            for each HID in m.focusItemStack.getAll()
1✔
1118
                m.focusItemStack.remove(HID)
1✔
1119
            end for
1120
            m.longPressTimer.unobserveFieldScoped("fire")
1✔
1121
            m.longPressTimer = invalid
1✔
1122
            m.widgetTree = invalid
1✔
1123
        end sub
1124

1125
    end class
1126

1127
    namespace FocusPluginHelper
1128

1129
        class BaseEntryStack extends Rotor.BaseStack
1130

1131
            function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
1132
                if ancestorHID <> "0"
3✔
1133
                    filteredStack = {}
1✔
1134
                    for each HID in m.stack
1✔
1135
                        if Rotor.Utils.isDescendantHID(HID, ancestorHID)
3✔
1136
                            filteredStack[HID] = m.get(HID)
1✔
1137
                        end if
1138
                    end for
1139
                else
3✔
1140
                    filteredStack = m.stack
1✔
1141
                end if
1142
                HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
1✔
1143
                return HID <> "" ? m.get(HID) : invalid
1✔
1144
            end function
1145

1146
            override sub remove(HID as string)
1147
                item = m.get(HID)
1✔
1148
                item.destroy()
1✔
1149
                super.remove(HID)
1✔
1150
            end sub
1151

1152
        end class
1153

1154
        class GroupStack extends BaseEntryStack
1155

1156
            function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1157
                foundHID = ""
×
1158
                for each HID in possibleGroups
×
1159
                    group = m.get(HID)
×
1160
                    if group.id = nodeId
×
1161
                        foundHID = group.HID
×
1162
                        exit for
1163
                    end if
1164
                end for
1165
                return foundHID
×
1166
            end function
1167

1168
        end class
1169

1170

1171
        class FocusItemStack extends BaseEntryStack
1172

1173
            function convertNodeIdToHID(nodeId as string, possibleFocusItems as object) as string
1174
                foundHID = ""
×
1175
                for each HID in possibleFocusItems
×
1176
                    focusItem = m.get(HID)
×
1177
                    if focusItem.id = nodeId
×
1178
                        foundHID = focusItem.HID
×
1179
                        exit for
1180
                    end if
1181
                end for
1182
                return foundHID
×
1183
            end function
1184

1185
            function hasEnabled(HID as string) as boolean
1186
                if m.has(HID)
×
1187
                    focusItem = m.get(HID)
×
1188
                    return focusItem.isEnabled
×
1189
                else
×
1190
                    return false
×
1191
                end if
1192
            end function
1193

1194
        end class
1195

1196
        class BaseFocusConfig
1197

1198
            autoSetIsFocusedState as boolean
1199
            staticDirection as object
1200

1201
            sub new (config as object)
1202

1203
                m.HID = config.HID
1✔
1204
                m.id = config.id
1✔
1205

1206
                m.widget = config.widget
1✔
1207
                m.node = m.widget.node
1✔
1208
                m.isFocused = config.isFocused ?? false
1✔
1209

1210
                m.autoSetIsFocusedState = config.autoSetIsFocusedState ?? true
1✔
1211

1212
                m.isEnabled = config.isEnabled ?? true
1✔
1213
                m.staticDirection = {}
1✔
1214
                m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1✔
1215
                m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
1✔
1216
                m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
1✔
1217
                m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
1✔
1218
                m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
1✔
1219

1220
                m.onFocusChanged = config.onFocusChanged
1✔
1221
                m.longPressHandler = config.longPressHandler
1✔
1222
                m.onFocus = config.onFocus
1✔
1223
                m.onBlur = config.onBlur
1✔
1224

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

1227
                ' convenience (usually this is used on viewModelState)
1228
                if false = m.widget.viewModelState.DoesExist("isFocused") and true = m.autoSetIsFocusedState
2✔
1229
                    m.widget.viewModelState.isFocused = false ' as default
1✔
1230
                end if
1231

1232
            end sub
1233

1234

1235
            HID as string
1236
            id as string
1237
            idByKeys as object
1238
            isEnabled as boolean
1239
            isFocused as boolean
1240
            onFocusChanged as dynamic
1241
            onFocus as dynamic
1242
            onBlur as dynamic
1243
            longPressHandler as dynamic
1244
            node as object
1245
            widget as object
1246

1247
            function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1248
                direction = m.staticDirection[direction]
1✔
1249
                if Rotor.Utils.isFunction(direction)
2✔
1250
                    return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
×
1251
                else
3✔
1252
                    return direction ?? ""
1253
                end if
1254
            end function
1255

1256
            sub callOnFocusedFnOnWidget(isFocused as boolean)
1257
                Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1✔
1258
                if true = isFocused
3✔
1259
                    Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1✔
1260
                else
3✔
1261
                    Rotor.Utils.callbackScoped(m.onBlur, m.widget)
1✔
1262
                end if
1263
            end sub
1264

1265
            function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1266
                if Rotor.Utils.isFunction(m.longPressHandler)
2✔
1267
                    return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
×
1268
                else
3✔
1269
                    return false
1✔
1270
                end if
1271
            end function
1272

1273
            sub destroy()
1274
                m.widget = invalid
1✔
1275
                m.node = invalid
1✔
1276
                m.onFocusChanged = invalid
1✔
1277
                m.onFocus = invalid
1✔
1278
                m.onBlur = invalid
1✔
1279
                m.longPressHandler = invalid
1✔
1280
            end sub
1281

1282
        end class
1283

1284
        class GroupClass extends BaseFocusConfig
1285
            ' Note: Spatial navigation is supported within group, there is no spatial navigation between groups
1286
            ' If you want to focus out to another group, you need to config a direction prop.
1287
            ' You can set a groupId or any focusItem widgetId.
1288
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1289
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1290

1291
            sub new (config as object)
1292
                super(config)
1✔
1293
                m.defaultFocusId = config.defaultFocusId ?? ""
1✔
1294
                m.lastFocusedHID = config.lastFocusedHID ?? ""
1✔
1295
                m.enableSpatialEnter = config.enableSpatialEnter ?? false
1✔
1296
                m.trackDescendantFocus = config.trackDescendantFocus ?? false
1✔
1297
            end sub
1298

1299
            defaultFocusId as string
1300
            lastFocusedHID as string
1301
            enableSpatialEnter as boolean
1302
            trackDescendantFocus as boolean
1303
            focusItemsRef as object
1304
            groupsRef as object
1305
            isFocusItem = false
1306
            isGroup = true
1307

1308
            sub setLastFocusedHID(lastFocusedHID as string)
1309
                m.lastFocusedHID = lastFocusedHID
1✔
1310
            end sub
1311

1312
            function getGroupMembersHIDs()
1313
                ' Collect all focusItems that are descendants of this group
1314
                ' Exclude items that belong to nested sub-groups
1315
                focusItems = m.focusItemsRef.getAll()
1✔
1316
                groups = m.groupsRef.getAll()
1✔
1317
                HIDlen = Len(m.HID)
1✔
1318
                collection = []
1✔
1319
                groupsKeys = groups.keys()
1✔
1320
                groupsCount = groups.Count()
1✔
1321

1322
                for each focusItemHID in focusItems
1✔
1323
                    ' Check if focusItem is a descendant of this group
1324
                    isDescendant = Left(focusItemHID, HIDlen) = m.HID
1✔
1325
                    if isDescendant
2✔
1326
                        ' Check if focusItem belongs to a nested sub-group
1327
                        shouldExclude = false
1✔
1328
                        otherGroupIndex = 0
1✔
1329
                        while shouldExclude = false and otherGroupIndex < groupsCount
1✔
1330
                            otherGroupHID = groupsKeys[otherGroupIndex]
1✔
1331
                            otherGroupHIDlen = Len(otherGroupHID)
1✔
1332
                            ' Exclude if belongs to deeper nested group
1333
                            shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
1✔
1334
                            otherGroupIndex++
1✔
1335
                        end while
1336

1337
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1338
                    end if
1339
                end for
1340

1341
                return collection
1✔
1342
            end function
1343

1344
            '
1345
            ' getFallbackNodeId - Returns the nodeId to use for fallback (defaultFocusId or lastFocusedHID)
1346
            '
1347
            ' @returns {string} The nodeId to use for fallback, or empty string if none
1348
            '
1349
            function getFallbackNodeId() as string
1350
                if m.lastFocusedHID <> ""
2✔
1351
                    ' Note: lastFocusedHID is already a HID, not a nodeId, so we need to get the nodeId
1352
                    lastFocusedItem = m.focusItemsRef.get(m.lastFocusedHID)
×
1353
                    if lastFocusedItem <> invalid
×
1354
                        return lastFocusedItem.id
×
1355
                    end if
1356
                end if
1357

1358
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
1359
                    return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1360
                else
3✔
1361
                    return m.defaultFocusId
1✔
1362
                end if
1363
            end function
1364

1365
            function getFallbackIdentifier(globalFocusHID = "" as string) as string
1366
                HID = ""
1✔
1367
                ' enableSpatialEnter takes priority over lastFocusedHID
1368
                ' (lastFocusedHID may be stale from slot recycling)
1369
                if not m.enableSpatialEnter and m.lastFocusedHID <> ""
2✔
1370
                    return m.lastFocusedHID
1✔
1371
                end if
1372

1373
                ' enableSpatialEnter: return the spatially closest member to the current focus
1374
                if m.enableSpatialEnter = true and globalFocusHID <> ""
2✔
1375
                    prevFocused = m.focusItemsRef.get(globalFocusHID)
1✔
1376
                    if prevFocused <> invalid
3✔
1377
                        prevFocused.refreshBounding()
1✔
1378
                        refPoint = prevFocused.metrics.middlePoint
1✔
1379
                        members = m.getGroupMembersHIDs()
1✔
1380
                        minDist = 2147483647
1✔
1381
                        closestHID = ""
1✔
1382
                        for each memberHID in members
1✔
1383
                            if memberHID <> globalFocusHID
3✔
1384
                                member = m.focusItemsRef.get(memberHID)
1✔
1385
                                if member <> invalid
3✔
1386
                                    member.refreshBounding()
1✔
1387
                                    dx = member.metrics.middlePoint.x - refPoint.x
1✔
1388
                                    dy = member.metrics.middlePoint.y - refPoint.y
1✔
1389
                                    dist = dx * dx + dy * dy
1✔
1390
                                    if dist < minDist
2✔
1391
                                        minDist = dist
1✔
1392
                                        closestHID = memberHID
1✔
1393
                                    end if
1394
                                end if
1395
                            end if
1396
                        end for
1397
                        if closestHID <> ""
3✔
1398
                            return closestHID
1✔
1399
                        end if
1400
                    end if
1401
                end if
1402

1403
                ' Default: use defaultFocusId expression
1404
                if Rotor.Utils.isFunction(m.defaultFocusId)
2✔
NEW
1405
                    defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
×
1406
                else
3✔
1407
                    defaultFocusId = m.defaultFocusId
1✔
1408
                end if
1409

1410
                if defaultFocusId <> ""
3✔
1411
                    focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1412
                    if focusItemsHIDlist.Count() > 0
3✔
1413

1414
                        ' Try find valid HID in focusItems by node id
1415
                        focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1✔
1416
                        if focusItemHID <> ""
3✔
1417
                            HID = focusItemHID
1✔
1418
                        end if
1419

1420
                    else
1421

3✔
1422
                        return defaultFocusId
1✔
1423

1424
                    end if
1425
                end if
1426

1427
                return HID
1✔
1428
            end function
1429

1430
            function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1431
                HID = ""
1✔
1432
                for each HID in focusItemsHIDlist
1✔
1433
                    focusItem = m.focusItemsRef.get(HID)
1✔
1434
                    if focusItem <> invalid and focusItem.id = nodeId
3✔
1435
                        HID = focusItem.HID
1✔
1436
                        exit for
1437
                    end if
1438
                end for
1439
                return HID
1✔
1440
            end function
1441

1442
            sub applyFocus(isFocused as boolean)
1443
                if m.isFocused = isFocused then return
2✔
1444

1445
                m.isFocused = isFocused
1✔
1446

1447
                if m.autoSetIsFocusedState
3✔
1448
                    m.widget.viewModelState.isInFocusChain = isFocused
1✔
1449
                end if
1450
                m.node.setField("isFocused", isFocused)
1✔
1451
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1452
            end sub
1453

1454
            override sub destroy()
1455
                super.destroy()
1✔
1456
                m.focusItemsRef = invalid
1✔
1457
                m.groupsRef = invalid
1✔
1458
            end sub
1459

1460

1461

1462
        end class
1463

1464
        class FocusItemClass extends BaseFocusConfig
1465

1466
            sub new (config as object)
1467
                super(config)
1✔
1468

1469
                m.onSelect = config.onSelect ?? ""
1✔
1470
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
1✔
1471
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1472
            end sub
1473

1474
            ' You can set a groupId or any focusItem widgetId.
1475
            ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1476
            ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1477

1478
            ' key as string
1479
            isFocusItem = true
1480
            isGroup = false
1481
            enableNativeFocus as boolean
1482
            enableSpatialNavigation as boolean
1483
            onSelect as dynamic
1484

1485
            private metrics = {
1486
                segments: {}
1487
            }
1488
            private bounding as object
1489

1490

1491
            sub refreshBounding()
1492
                b = m.node.sceneBoundingRect()
1✔
1493
                rotation = m.node.rotation
1✔
1494

1495
                ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1496
                ' That is why we can use translation without knowing the value of inheritParentTransform
1497
                ' If bounding x or y are not zero, then bounding will include the node's translation
1498
                if rotation = 0
3✔
1499
                    if b.y = 0 and b.x = 0
2✔
1500
                        t = m.node.translation
1✔
1501
                        b.x += t[0]
1✔
1502
                        b.y += t[1]
1✔
1503
                    end if
1504

1505
                    m.metrics.append(b)
1✔
1506
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = {
1✔
1507
                        x1: b.x, y1: b.y,
1508
                        x2: b.x, y2: b.y + b.height
1509
                    }
1510
                    m.metrics.segments[Rotor.Const.Segment.TOP] = {
1✔
1511
                        x1: b.x, y1: b.y,
1512
                        x2: b.x + b.width, y2: b.y
1513
                    }
1514
                    m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
1✔
1515
                        x1: b.x + b.width, y1: b.y,
1516
                        x2: b.x + b.width, y2: b.y + b.height
1517
                    }
1518
                    m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
1✔
1519
                        x1: b.x, y1: b.y + b.height,
1520
                        x2: b.x + b.width, y2: b.y + b.height
1521
                    }
1522
                    m.metrics.middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
1✔
1523
                else
×
1524
                    scaleRotateCenter = m.node.scaleRotateCenter
×
1525
                    dims = m.node.localBoundingRect() ' We need this to get proper (rotated value of rotated x and y)
×
1526
                    if b.y = 0 and b.x = 0
×
1527
                        t = m.node.translation
×
1528
                        b.x += t[0]
×
1529
                        b.y += t[1]
×
1530
                    end if
1531
                    b.width = dims.width
×
1532
                    b.height = dims.height
×
1533
                    m.metrics.append(b)
×
1534

1535
                    ' Calculate rotated segments
1536
                    segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
×
1537
                    rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
×
1538
                    m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
×
1539

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

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

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

1552
                    ' Calculate rotated middle point
1553
                    middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
×
1554
                    rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
×
1555
                    m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
×
1556

1557
                end if
1558
            end sub
1559

1560
            override sub destroy()
1561
                m.onSelect = invalid
1✔
1562
                m.metrics.segments.Clear()
1✔
1563
                super.destroy()
1✔
1564
            end sub
1565

1566
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1567
                if m.isFocused = isFocused then return
2✔
1568

1569
                m.isFocused = isFocused
1✔
1570

1571
                if m.autoSetIsFocusedState
3✔
1572
                    m.widget.viewModelState.isFocused = isFocused
1✔
1573
                end if
1574

1575
                m.node.setField("isFocused", isFocused)
1✔
1576

1577
                if enableNativeFocus or m.enableNativeFocus
2✔
1578
                    m.node.setFocus(isFocused)
×
1579
                end if
1580

1581
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1582

1583
            end sub
1584

1585
            sub callOnSelectFnOnWidget()
1586
                Rotor.Utils.callbackScoped(m.onSelect, m.widget)
1✔
1587
            end sub
1588

1589
        end class
1590

1591
        class ClosestSegmentToPointCalculatorClass
1592

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

1596
                A = x - x1
1✔
1597
                B = y - y1
1✔
1598
                C = x2 - x1
1✔
1599
                D = y2 - y1
1✔
1600

1601
                dot = A * C + B * D
1✔
1602
                len_sq = C * C + D * D
1✔
1603
                param = -1
1✔
1604
                if len_sq <> 0
3✔
1605
                    param = dot / len_sq
1✔
1606
                end if
1607

1608
                xx = 0
1✔
1609
                yy = 0
1✔
1610

1611
                if param < 0
2✔
1612
                    xx = x1
×
1613
                    yy = y1
×
1614
                else if param > 1
2✔
1615
                    xx = x2
×
1616
                    yy = y2
×
1617
                else
3✔
1618
                    xx = x1 + param * C
1✔
1619
                    yy = y1 + param * D
1✔
1620
                end if
1621

1622
                dx = x - xx
1✔
1623
                dy = y - yy
1✔
1624
                return dx * dx + dy * dy
1✔
1625
            end function
1626

1627
            function distToSegment(p as object, s1 as object, s2 as object)
1628
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1629
            end function
1630

1631
        end class
1632

1633
    end namespace
1634

1635
    namespace FocusPluginHelper
1636

1637
        sub longPressObserverCallback(msg)
1638
            extraInfo = msg.GetInfo()
1✔
1639

1640
            pluginKey = extraInfo["pluginKey"]
1✔
1641

1642
            globalScope = GetGlobalAA()
1✔
1643
            frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
1✔
1644
            plugin = frameworkInstance.plugins[pluginKey]
1✔
1645
            plugin.isLongPress = true
1✔
1646
            ' plugin.longPressStartHID = plugin.globalFocusHID
1647
            plugin.delegateLongPressChanged(true, plugin.longPressKey)
1✔
1648

1649
        end sub
1650

1651
    end namespace
1652

1653
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