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

mobalazs / rotor-framework / 21451997727

28 Jan 2026 07:11PM UTC coverage: 86.779% (-0.04%) from 86.821%
21451997727

push

github

mobalazs
fix(Focus Plugin): respect navigation is disabled  when long press

0 of 1 new or added line in 1 file covered. (0.0%)

3 existing lines in 2 files now uncovered.

2061 of 2375 relevant lines covered (86.78%)

1.2 hits per line

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

82.23
/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
3✔
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
2✔
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
NEW
1051
            if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(m.longPressKey, false, false)
×
UNCOV
1052
            return m.executeNavigationAction(m.longPressKey, true)
×
1053
        end function
1054

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

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

1065
            validators = {
1✔
1066

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

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

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

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

1111
            return segments
1✔
1112
        end function
1113

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

1128
    end class
1129

1130
    namespace FocusPluginHelper
1131

1132
        class BaseEntryStack extends Rotor.BaseStack
1133

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

1149
            override sub remove(HID as string)
1150
                item = m.get(HID)
1✔
1151
                item.destroy()
1✔
1152
                super.remove(HID)
1✔
1153
            end sub
1154

1155
        end class
1156

1157
        class GroupStack extends BaseEntryStack
1158

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

1171
        end class
1172

1173

1174
        class FocusItemStack extends BaseEntryStack
1175

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

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

1197
        end class
1198

1199
        class BaseFocusConfig
1200

1201
            autoSetIsFocusedState as boolean
1202
            staticDirection as object
1203

1204
            sub new (config as object)
1205

1206
                m.HID = config.HID
1✔
1207
                m.id = config.id
1✔
1208

1209
                m.widget = config.widget
1✔
1210
                m.node = m.widget.node
1✔
1211
                m.isFocused = config.isFocused ?? false
1✔
1212

1213
                m.autoSetIsFocusedState = config.autoSetIsFocusedState ?? true
1✔
1214

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

1223
                m.onFocusChanged = config.onFocusChanged
1✔
1224
                m.longPressHandler = config.longPressHandler
1✔
1225
                m.onFocus = config.onFocus
1✔
1226
                m.onBlur = config.onBlur
1✔
1227

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

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

1235
            end sub
1236

1237

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

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

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

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

1276
            sub destroy()
1277
                m.widget = invalid
1✔
1278
                m.node = invalid
1✔
1279
                m.onFocusChanged = invalid
1✔
1280
                m.onFocus = invalid
1✔
1281
                m.onBlur = invalid
1✔
1282
                m.longPressHandler = invalid
1✔
1283
            end sub
1284

1285
        end class
1286

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

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

1302
            defaultFocusId as string
1303
            lastFocusedHID as string
1304
            enableSpatialEnter as boolean
1305
            trackDescendantFocus as boolean
1306
            focusItemsRef as object
1307
            groupsRef as object
1308
            isFocusItem = false
1309
            isGroup = true
1310

1311
            sub setLastFocusedHID(lastFocusedHID as string)
1312
                m.lastFocusedHID = lastFocusedHID
1✔
1313
            end sub
1314

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

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

1340
                        if not shouldExclude then collection.push(focusItemHID)
1✔
1341
                    end if
1342
                end for
1343

1344
                return collection
1✔
1345
            end function
1346

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

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

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

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

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

1413
                if defaultFocusId <> ""
3✔
1414
                    focusItemsHIDlist = m.getGroupMembersHIDs()
1✔
1415
                    if focusItemsHIDlist.Count() > 0
3✔
1416

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

1423
                    else
1424

3✔
1425
                        return defaultFocusId
1✔
1426

1427
                    end if
1428
                end if
1429

1430
                return HID
1✔
1431
            end function
1432

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

1445
            sub applyFocus(isFocused as boolean)
1446
                if m.isFocused = isFocused then return
2✔
1447

1448
                m.isFocused = isFocused
1✔
1449

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

1457
            override sub destroy()
1458
                super.destroy()
1✔
1459
                m.focusItemsRef = invalid
1✔
1460
                m.groupsRef = invalid
1✔
1461
            end sub
1462

1463

1464

1465
        end class
1466

1467
        class FocusItemClass extends BaseFocusConfig
1468

1469
            sub new (config as object)
1470
                super(config)
1✔
1471

1472
                m.onSelect = config.onSelect ?? ""
1✔
1473
                m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
1✔
1474
                m.enableNativeFocus = config.enableNativeFocus ?? false
1✔
1475
            end sub
1476

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

1481
            ' key as string
1482
            isFocusItem = true
1483
            isGroup = false
1484
            enableNativeFocus as boolean
1485
            enableSpatialNavigation as boolean
1486
            onSelect as dynamic
1487

1488
            private metrics = {
1489
                segments: {}
1490
            }
1491
            private bounding as object
1492

1493

1494
            sub refreshBounding()
1495
                b = m.node.sceneBoundingRect()
1✔
1496
                rotation = m.node.rotation
1✔
1497

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

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

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

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

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

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

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

1560
                end if
1561
            end sub
1562

1563
            override sub destroy()
1564
                m.onSelect = invalid
1✔
1565
                m.metrics.segments.Clear()
1✔
1566
                super.destroy()
1✔
1567
            end sub
1568

1569
            sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1570
                if m.isFocused = isFocused then return
2✔
1571

1572
                m.isFocused = isFocused
1✔
1573

1574
                if m.autoSetIsFocusedState
3✔
1575
                    m.widget.viewModelState.isFocused = isFocused
1✔
1576
                end if
1577

1578
                m.node.setField("isFocused", isFocused)
1✔
1579

1580
                if enableNativeFocus or m.enableNativeFocus
2✔
1581
                    m.node.setFocus(isFocused)
×
1582
                end if
1583

1584
                m.callOnFocusedFnOnWidget(isFocused)
1✔
1585

1586
            end sub
1587

1588
            sub callOnSelectFnOnWidget()
1589
                Rotor.Utils.callbackScoped(m.onSelect, m.widget)
1✔
1590
            end sub
1591

1592
        end class
1593

1594
        class ClosestSegmentToPointCalculatorClass
1595

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

1599
                A = x - x1
1✔
1600
                B = y - y1
1✔
1601
                C = x2 - x1
1✔
1602
                D = y2 - y1
1✔
1603

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

1611
                xx = 0
1✔
1612
                yy = 0
1✔
1613

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

1625
                dx = x - xx
1✔
1626
                dy = y - yy
1✔
1627
                return dx * dx + dy * dy
1✔
1628
            end function
1629

1630
            function distToSegment(p as object, s1 as object, s2 as object)
1631
                return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1✔
1632
            end function
1633

1634
        end class
1635

1636
    end namespace
1637

1638
    namespace FocusPluginHelper
1639

1640
        sub longPressObserverCallback(msg)
1641
            extraInfo = msg.GetInfo()
1✔
1642

1643
            pluginKey = extraInfo["pluginKey"]
1✔
1644

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

1652
        end sub
1653

1654
    end namespace
1655

1656
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