rotor-framework 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +120 -0
  3. package/package.json +59 -0
  4. package/src/source/RotorFramework.bs +654 -0
  5. package/src/source/RotorFrameworkTask.bs +278 -0
  6. package/src/source/base/BaseModel.bs +52 -0
  7. package/src/source/base/BasePlugin.bs +48 -0
  8. package/src/source/base/BaseReducer.bs +184 -0
  9. package/src/source/base/BaseStack.bs +92 -0
  10. package/src/source/base/BaseViewModel.bs +124 -0
  11. package/src/source/base/BaseWidget.bs +104 -0
  12. package/src/source/base/DispatcherCreator.bs +193 -0
  13. package/src/source/base/DispatcherExternal.bs +260 -0
  14. package/src/source/base/ListenerForDispatchers.bs +246 -0
  15. package/src/source/engine/Constants.bs +74 -0
  16. package/src/source/engine/animator/Animator.bs +334 -0
  17. package/src/source/engine/builder/Builder.bs +213 -0
  18. package/src/source/engine/builder/NodePool.bs +236 -0
  19. package/src/source/engine/builder/PluginAdapter.bs +139 -0
  20. package/src/source/engine/builder/PostProcessor.bs +331 -0
  21. package/src/source/engine/builder/Processor.bs +156 -0
  22. package/src/source/engine/builder/Tree.bs +278 -0
  23. package/src/source/engine/builder/TreeBase.bs +313 -0
  24. package/src/source/engine/builder/WidgetCreate.bs +322 -0
  25. package/src/source/engine/builder/WidgetRemove.bs +72 -0
  26. package/src/source/engine/builder/WidgetUpdate.bs +113 -0
  27. package/src/source/engine/providers/Dispatcher.bs +72 -0
  28. package/src/source/engine/providers/DispatcherProvider.bs +95 -0
  29. package/src/source/engine/services/I18n.bs +169 -0
  30. package/src/source/libs/animate/Animate.bs +753 -0
  31. package/src/source/libs/animate/LICENSE.txt +21 -0
  32. package/src/source/plugins/DispatcherProviderPlugin.bs +127 -0
  33. package/src/source/plugins/FieldsPlugin.bs +180 -0
  34. package/src/source/plugins/FocusPlugin.bs +1522 -0
  35. package/src/source/plugins/FontStylePlugin.bs +159 -0
  36. package/src/source/plugins/ObserverPlugin.bs +548 -0
  37. package/src/source/utils/ArrayUtils.bs +495 -0
  38. package/src/source/utils/GeneralUtils.bs +181 -0
  39. package/src/source/utils/NodeUtils.bs +180 -0
@@ -0,0 +1,1522 @@
1
+ ' TODO: Future improvement: integrate deviceinfo TimeSinceLastKeypress() -> idle time
2
+ ' TODO: Future improvement: key combo detector
3
+
4
+ namespace Rotor
5
+
6
+ ' =====================================================================
7
+ ' FocusPlugin - Handles focus logic, focus groups, and spatial navigation
8
+ '
9
+ ' A Brighterscript class for handling focus logic, focus groups,
10
+ ' and spatial navigation within the Rotor framework.
11
+ '
12
+ ' ═══════════════════════════════════════════════════════════════
13
+ ' CONCEPTUAL OVERVIEW: BUBBLING vs CAPTURING
14
+ ' ═══════════════════════════════════════════════════════════════
15
+ '
16
+ ' This plugin implements two complementary focus resolution strategies:
17
+ '
18
+ ' 1. BUBBLING FOCUS (bubblingFocus) - "Upward Search"
19
+ ' ┌─────────────────────────────────────────────────────────┐
20
+ ' │ WHEN: User interaction (key press) cannot find target │
21
+ ' │ DIRECTION: Child → Parent → Grandparent (upward) │
22
+ ' │ PURPOSE: "I can't navigate further, ask my parents" │
23
+ ' └─────────────────────────────────────────────────────────┘
24
+ '
25
+ ' Example: User presses UP from a focused item, but there's
26
+ ' no item above. The plugin "bubbles up" through ancestor
27
+ ' groups to find an alternative navigation path defined at
28
+ ' a higher level.
29
+ '
30
+ ' 2. CAPTURING FOCUS (capturingFocus_recursively) - "Downward Rescue"
31
+ ' ┌─────────────────────────────────────────────────────────┐
32
+ ' │ WHEN: Need to resolve abstract target to concrete item │
33
+ ' │ DIRECTION: Group → Nested Group → FocusItem (downward) │
34
+ ' │ PURPOSE: "Found a group/ID, find the actual item" │
35
+ ' └─────────────────────────────────────────────────────────┘
36
+ '
37
+ ' This is a "rescue operation" that converts:
38
+ ' - Group reference → concrete FocusItem
39
+ ' - ID string → actual widget with focus capability
40
+ '
41
+ ' Example: Bubbling found "menuGroup", but we need a specific
42
+ ' focusable item. Capturing recursively descends through the
43
+ ' group's defaultFocusId chain until it finds a real FocusItem.
44
+ '
45
+ '
46
+ ' DEEP SEARCH ENHANCEMENT:
47
+ ' The capturing process now searches deeply in hierarchies.
48
+ ' If defaultFocusId doesn't match immediate children, it will:
49
+ ' - Search all descendant FocusItems (any depth)
50
+ ' - Search all nested Groups (any depth)
51
+ ' - Apply fallback logic if a matching Group is found
52
+ '
53
+ ' This means defaultFocusId: "deepItem" will find "deepItem"
54
+ ' even if it's 3+ levels deep in the hierarchy!
55
+ '
56
+ ' TOGETHER THEY WORK AS:
57
+ ' User Action → Bubbling (↑ find alternative) → Capturing (↓ resolve target)
58
+ '
59
+ ' ═══════════════════════════════════════════════════════════════
60
+ ' COMPLETE RULES REFERENCE
61
+ ' ═══════════════════════════════════════════════════════════════
62
+ '
63
+ ' RULE #1: Widget Types
64
+ ' - focus: { group: {...} } → Group (container)
65
+ ' - focus: {...} (no group key) → FocusItem (focusable element)
66
+ ' - No focus config → Not part of focus system
67
+ '
68
+ ' RULE #2: FocusItem Direction Values
69
+ ' - String (Node ID): Static navigation to that element
70
+ ' - Function: Dynamic, evaluated at runtime
71
+ ' - false: Blocks the direction (nothing happens)
72
+ ' - true/undefined/empty string: Spatial navigation attempts
73
+ '
74
+ ' RULE #3: Navigation Priority (Decreasing Order)
75
+ ' 1. FocusItem static direction (left: "button2")
76
+ ' 2. Spatial navigation (within group only)
77
+ ' 3. BubblingFocus (ask parent groups)
78
+ '
79
+ ' RULE #4: Spatial Navigation Scope
80
+ ' - ONLY works within a single group
81
+ ' - Cannot cross into sibling or parent groups
82
+ ' - Searches only possibleFocusItems from group.getGroupMembersHIDs()
83
+ '
84
+ ' RULE #5: Group Direction Activation
85
+ ' Group direction triggers ONLY when:
86
+ ' - FocusItem has NO static direction
87
+ ' - Spatial navigation found NOTHING
88
+ ' - BubblingFocus reaches this group
89
+ '
90
+ ' RULE #6: Group Direction Values
91
+ ' - String (Node ID): Navigate to that group/item (may EXIT group)
92
+ ' - true: BLOCKS (stays on current element)
93
+ ' - false/undefined: Continue bubbling to next ancestor
94
+ '
95
+ ' RULE #7: Group Direction Does NOT Block Spatial Navigation
96
+ ' Setting group.right = true does NOT prevent spatial navigation
97
+ ' INSIDE the group. It only blocks EXITING the group when spatial
98
+ ' navigation finds nothing.
99
+ '
100
+ ' RULE #8: Exiting a Group - 3 Methods
101
+ ' Method 1: FocusItem explicit direction
102
+ ' focusItem.right = "otherGroupItem" → EXITS immediately
103
+ ' Method 2: Group direction (via BubblingFocus)
104
+ ' group.right = "otherGroup" → EXITS when spatial nav fails
105
+ ' Method 3: Ancestor group direction
106
+ ' parentGroup.right = "otherGroup" → EXITS when child groups pass
107
+ '
108
+ ' RULE #9: Blocking Group Exit
109
+ ' To prevent exit: group.left = true, group.right = true
110
+ ' Exception: FocusItem explicit directions still work!
111
+ '
112
+ ' RULE #10: BubblingFocus Flow
113
+ ' FocusItem (no direction) → Spatial nav (nothing) → Group.direction?
114
+ ' - "nodeId" → CapturingFocus(nodeId) [EXIT]
115
+ ' - true → STOP (stay on current)
116
+ ' - false/undefined → Continue to parent group
117
+ ' - No more ancestors → Stay on current
118
+ '
119
+ ' RULE #11: CapturingFocus Priority
120
+ ' 1. group.lastFocusedHID (if exists) [AUTO-SAVED]
121
+ ' 2. group.defaultFocusId [CONFIGURED]
122
+ ' 3. Deep search (if defaultFocusId not found immediately)
123
+ '
124
+ ' RULE #12: DefaultFocusId Targets
125
+ ' - FocusItem node ID → Focus goes directly to it
126
+ ' - Group node ID → Capturing continues on that group
127
+ ' - Non-existent ID → Deep search attempts
128
+ '
129
+ ' RULE #13: Deep Search Activation
130
+ ' Triggers when:
131
+ ' - CapturingFocus doesn't find defaultFocusId in immediate children
132
+ ' - defaultFocusId is not empty
133
+ ' Searches:
134
+ ' 1. All descendant FocusItems (any depth)
135
+ ' 2. All nested Groups (any depth, applies their fallback)
136
+ '
137
+ ' RULE #14: Spatial Enter
138
+ ' When enableSpatialEnter = true on a group:
139
+ ' - Entering the group uses spatial navigation from the direction
140
+ ' - Finds geometrically closest item instead of defaultFocusId
141
+ ' - Falls back to defaultFocusId if spatial finds nothing
142
+ '
143
+ ' RULE #15: Navigation Decision Tree Summary
144
+ ' User presses direction key:
145
+ ' 1. FocusItem.direction exists? → Use it (may EXIT group)
146
+ ' 2. Spatial nav finds item? → Navigate (STAYS in group)
147
+ ' 3. BubblingFocus: Group.direction?
148
+ ' - "nodeId" → EXIT to that target
149
+ ' - true → BLOCK (stay)
150
+ ' - undefined → Continue to ancestor
151
+ ' 4. No more ancestors? → STAY on current item
152
+ '
153
+ ' COMMON PATTERNS:
154
+ ' Sidebar + Content:
155
+ ' sidebar: { group: { right: true } }
156
+ ' menuItem1: { right: "contentFirst" } [explicit exit]
157
+ '
158
+ ' Modal Dialog (locked):
159
+ ' modal: { group: { left: true, right: true, up: true, down: true } }
160
+ '
161
+ ' Nested Navigation:
162
+ ' innerGroup: { group: { down: undefined } } [no direction]
163
+ ' outerGroup: { group: { down: "bottomBar" } } [catches bubbling]
164
+ '
165
+ ' =====================================================================
166
+ class FocusPlugin extends Rotor.BasePlugin
167
+
168
+ ' ---------------------------------------------------------------------
169
+ ' new - Constructor for the FocusPlugin
170
+ '
171
+ ' @param {string} key - The key to identify this plugin instance (default: "focus")
172
+ '
173
+ sub new(key = "focus" as string)
174
+ super(key)
175
+ end sub
176
+
177
+ ' Framework lifecycle hooks
178
+ hooks = {
179
+ ' ---------------------------------------------------------------------
180
+ ' beforeMount - Hook executed before a widget is mounted
181
+ '
182
+ ' Sets initial focus config.
183
+ '
184
+ ' @param {object} scope - The plugin scope (this instance)
185
+ ' @param {object} widget - The widget being mounted
186
+ '
187
+ beforeMount: sub(scope as object, widget as object)
188
+ scope.setFocusConfig(widget, widget[scope.key])
189
+ end sub,
190
+
191
+ ' ---------------------------------------------------------------------
192
+ ' beforeUpdate - Hook executed before a widget is updated
193
+ '
194
+ ' Removes old config, applies new.
195
+ '
196
+ ' @param {object} scope - The plugin scope (this instance)
197
+ ' @param {object} widget - The widget being updated
198
+ ' @param {dynamic} newValue - The new plugin configuration value
199
+ ' @param {object} oldValue - The previous plugin configuration value (default: {})
200
+ '
201
+ beforeUpdate: sub(scope as object, widget as object, newValue, oldValue = {})
202
+ ' Remove previous config before applying the update
203
+ scope.removeFocusConfig(widget.HID)
204
+
205
+ ' Merge new config into existing widget config
206
+ Rotor.Utils.deepExtendAA(widget[scope.key], newValue)
207
+ scope.setFocusConfig(widget, widget[scope.key])
208
+ end sub,
209
+
210
+ ' ---------------------------------------------------------------------
211
+ ' beforeDestroy - Hook executed before a widget is destroyed
212
+ '
213
+ ' Removes focus config.
214
+ '
215
+ ' @param {object} scope - The plugin scope (this instance)
216
+ ' @param {object} widget - The widget being destroyed
217
+ '
218
+ beforeDestroy: sub(scope as object, widget as object)
219
+ scope.removeFocusConfig(widget.HID)
220
+ end sub
221
+ }
222
+
223
+ ' Widget methods - Injected into widgets managed by this plugin
224
+ widgetMethods = {
225
+
226
+ ' ---------------------------------------------------------------------
227
+ ' enableFocusNavigation - Enables or disables focus navigation globally for this plugin
228
+ '
229
+ ' @param {boolean} enableFocusNavigation - True to enable, false to disable (default: true)
230
+ '
231
+ enableFocusNavigation: sub(enableFocusNavigation = true as boolean)
232
+ globalScope = GetGlobalAA()
233
+ pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
234
+ globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].enableFocusNavigation = enableFocusNavigation
235
+ end sub,
236
+
237
+ ' ---------------------------------------------------------------------
238
+ ' isFocusNavigationEnabled - Checks if focus navigation is currently enabled globally
239
+ '
240
+ ' @returns {boolean} True if enabled, false otherwise
241
+ '
242
+ isFocusNavigationEnabled: function() as boolean
243
+ globalScope = GetGlobalAA()
244
+ pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
245
+ return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].enableFocusNavigation
246
+ end function,
247
+
248
+ ' ---------------------------------------------------------------------
249
+ ' setFocus - Sets focus to this widget or another specified widget
250
+ '
251
+ ' @param {dynamic} isFocused - Boolean to focus/blur current widget, or string ID/HID of widget to focus
252
+ ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node
253
+ ' @returns {boolean} True if focus state was changed successfully, false otherwise
254
+ '
255
+ setFocus: function(command = true as dynamic, enableNativeFocus = false as boolean) as boolean
256
+ globalScope = GetGlobalAA()
257
+ pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
258
+ HID = m.HID ' Widget's unique Hierarchical ID (bound viewModelState).
259
+
260
+ if Rotor.Utils.isString(command)
261
+ ' If string, focus widget by ID/HID.
262
+ otherId = command
263
+ return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].setFocus(otherId, true, enableNativeFocus)
264
+ else ' Boolean value provided
265
+ ' If boolean, focus/blur current widget.
266
+ return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].setFocus(HID, command, enableNativeFocus)
267
+ end if
268
+ end function,
269
+
270
+ ' ---------------------------------------------------------------------
271
+ ' getFocusedWidget - Retrieves the currently focused widget managed by this plugin
272
+ '
273
+ ' @returns {object} The widget instance that currently holds focus, or invalid
274
+ '
275
+ getFocusedWidget: function() as object ' Params isFocused, enableNativeFocus seem unused here
276
+ globalScope = GetGlobalAA()
277
+ pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
278
+ return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].getFocusedWidget()
279
+ end function,
280
+
281
+ ' ---------------------------------------------------------------------
282
+ ' proceedLongPress - Manually triggers the navigation action associated with the current long-press key
283
+ '
284
+ ' @returns {object} The result of the executed navigation action (see parseOnKeyEventResult)
285
+ '
286
+ proceedLongPress: function() as object ' Params isFocused, enableNativeFocus seem unused here
287
+ globalScope = GetGlobalAA()
288
+ pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
289
+ return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].proceedLongPress()
290
+ end function,
291
+
292
+ ' ---------------------------------------------------------------------
293
+ ' isLongPressActive - Checks if a long press action is currently active
294
+ '
295
+ ' @returns {boolean} True if a long press is active, false otherwise
296
+ '
297
+ isLongPressActive: function() as boolean ' Params isFocused, enableNativeFocus seem unused here
298
+ globalScope = GetGlobalAA()
299
+ pluginKey = m.pluginKey ' Plugin's key in the widget's plugin dictionary.
300
+ return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].isLongPress
301
+ end function,
302
+
303
+ ' ---------------------------------------------------------------------
304
+ ' triggerKeyPress - Simulate key press
305
+ '
306
+ ' @param {string} key - Pressed key
307
+ ' @returns {object} The widget instance that currently holds focus, or invalid
308
+ '
309
+ triggerKeyPress: function(key) as object
310
+ globalScope = GetGlobalAA()
311
+ pluginKey = m.pluginKey
312
+ return globalScope.rotor_framework_helper.frameworkInstance.plugins[pluginKey].onKeyEventHandler(key, true)
313
+ end function
314
+
315
+ }
316
+
317
+ ' Configuration
318
+ longPressDuration = 0.4
319
+ enableLongPressFeature = true
320
+ enableFocusNavigation = true
321
+
322
+ ' State tracking
323
+ globalFocusHID = ""
324
+ globalFocusId = ""
325
+ isLongPress = false
326
+ longPressKey = ""
327
+
328
+ ' References
329
+ widgetTree as object
330
+ frameworkInstance as Rotor.Framework
331
+
332
+ ' Helper objects
333
+ focusItemStack = new Rotor.FocusPluginHelper.FocusItemStack()
334
+ groupStack = new Rotor.FocusPluginHelper.GroupStack()
335
+ distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass()
336
+ longPressTimer = CreateObject("roSGNode", "Timer")
337
+
338
+ ' ---------------------------------------------------------------------
339
+ ' init - Initializes the plugin instance
340
+ '
341
+ ' Sets up internal state and helpers.
342
+ '
343
+ sub init ()
344
+ m.widgetTree = m.frameworkInstance.builder.widgetTree ' Reference to the main widget tree
345
+ m.longPressTimer.addField("pluginKey", "string", false)
346
+ m.longPressTimer.setFields({
347
+ "pluginKey": m.key,
348
+ duration: m.longPressDuration
349
+ })
350
+ ' Observe timer fire event to handle long press callback
351
+ m.longPressTimer.observeFieldScoped("fire", "Rotor_FocusPluginHelper_longPressObserverCallback", ["pluginKey"])
352
+ end sub
353
+
354
+ '
355
+ ' storeGlobalFocusHID - Stores the globally focused widget's HID and ID
356
+ '
357
+ ' @param {string} HID - The Hierarchical ID of the focused widget
358
+ ' @param {string} id - The regular ID of the focused widget
359
+ '
360
+ sub storeGlobalFocusHID(HID as string, id as string)
361
+ ' Store focus reference within the plugin
362
+ m.globalFocusHID = HID
363
+ m.globalFocusId = id
364
+ end sub
365
+
366
+ '
367
+ ' getFocusedWidget - Gets the widget instance that currently holds global focus
368
+ '
369
+ ' @returns {object} The focused widget object, or invalid if none
370
+ '
371
+ function getFocusedWidget() as object
372
+ return m.getFocusedItem()?.widget
373
+ end function
374
+
375
+ '
376
+ ' getFocusedItem - Gets the FocusItem instance corresponding to the globally focused widget
377
+ '
378
+ ' @returns {object} The FocusItem instance, or invalid if none
379
+ '
380
+ function getFocusedItem() as object
381
+ return m.focusItemStack.get(m.globalFocusHID)
382
+ end function
383
+
384
+ '
385
+ ' setFocusConfig - Configures focus properties (FocusItem and/or Group) for a widget
386
+ '
387
+ ' @param {object} widget - The widget to configure
388
+ ' @param {object} pluginConfig - The focus configuration object from the widget's spec
389
+ '
390
+ sub setFocusConfig(widget as object, pluginConfig as object)
391
+
392
+ if pluginConfig = invalid then return ' No config provided
393
+ HID = widget.HID
394
+ id = widget.id
395
+
396
+ ' Make a copy to avoid modifying the original config
397
+ config = Rotor.Utils.deepCopy(pluginConfig)
398
+ isGroup = config.doesExist(Rotor.Const.GROUP_CONFIG_KEY)
399
+ ' An item is focusItem if it's explicitly a group with other props, or not a group but has props.
400
+ ' isFocusItem = (isGroup = true and config.Count() > 1) or (isGroup = false and config.Count() > 0)
401
+
402
+ ' Ensure essential identifiers are in the config
403
+ config.id = id
404
+ config.HID = widget.HID
405
+
406
+ ' Handle group configuration if present
407
+ if isGroup
408
+ m.setupGroup(HID, config, widget)
409
+ else
410
+ ' Handle focus item configuration if applicable
411
+ ' if isFocusItem
412
+ m.setupFocusItem(HID, config, widget)
413
+ end if
414
+ end sub
415
+
416
+ '
417
+ ' setupGroup - Creates and registers a new Focus Group based on configuration
418
+ '
419
+ ' @param {string} HID - The Hierarchical ID of the widget acting as the group root
420
+ ' @param {object} config - The full focus configuration for the widget
421
+ ' @param {object} widget - The widget instance itself
422
+ '
423
+ sub setupGroup(HID as string, config as object, widget as object)
424
+ groupConfig = config[Rotor.Const.GROUP_CONFIG_KEY]
425
+ ' Copy essential info to the group-specific config
426
+ groupConfig.id = config.id
427
+ groupConfig.HID = config.HID
428
+ groupConfig.widget = widget
429
+ ' Create and configure the Group instance
430
+ newGroup = new Rotor.FocusPluginHelper.GroupClass(groupConfig)
431
+ newGroup.focusItemsRef = m.focusItemStack ' Provide reference to focus items
432
+ newGroup.groupsRef = m.groupStack ' Provide reference to other groups
433
+ m.groupStack.set(config.HID, newGroup) ' Register the new group
434
+ end sub
435
+
436
+ '
437
+ ' setupFocusItem - Creates and registers a new Focus Item based on configuration
438
+ '
439
+ ' @param {string} HID - The Hierarchical ID of the focusItem widget
440
+ ' @param {object} config - The full focus configuration for the widget
441
+ ' @param {object} widget - The widget instance itself
442
+ '
443
+ sub setupFocusItem(HID as string, config as object, widget as object)
444
+ config.widget = widget ' Ensure widget reference is in the config
445
+
446
+ ' Create and register the FocusItem instance
447
+ newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
448
+ m.focusItemStack.set(HID, newFocusItem)
449
+ end sub
450
+
451
+ '
452
+ ' findAncestorGroups - Finds all ancestor groups for a given widget HID
453
+ '
454
+ ' @param {string} HID - The Hierarchical ID of the widget
455
+ ' @returns {object} An roArray of ancestor group HIDs, sorted with the immediate parent first (descending HID length)
456
+ '
457
+ function findAncestorGroups(HID as string) as object
458
+ allGroups = m.groupStack.getAll() ' Get all registered groups
459
+ ancestorGroups = []
460
+ ' Iterate through all groups to find ancestors
461
+ for each groupHID in allGroups
462
+ if Rotor.Utils.isAncestorHID(groupHID, HID)
463
+ ancestorGroups.push(groupHID)
464
+ end if
465
+ end for
466
+ ' Sort by HID length descending (parent first)
467
+ ancestorGroups.Sort("r")
468
+
469
+ ' Note:
470
+ ' - Parent group is at index 0
471
+ ' - If HID is a focusItem, its direct parent group is included
472
+ ' - If HID is a group, the group itself is NOT included
473
+ return ancestorGroups
474
+ end function
475
+
476
+ '
477
+ ' removeFocusConfig - Removes focus configuration (Group and/or FocusItem) for a widget
478
+ '
479
+ ' @param {string} HID - The Hierarchical ID of the widget whose config should be removed
480
+ '
481
+ sub removeFocusConfig(HID as string)
482
+ ' Remove associated group, if it exists
483
+ if m.groupStack.has(HID)
484
+ m.groupStack.remove(HID)
485
+ end if
486
+ ' Remove associated focus item, if it exists
487
+ if m.focusItemStack.has(HID)
488
+ m.focusItemStack.remove(HID)
489
+ end if
490
+ end sub
491
+
492
+ '
493
+ ' setFocus - Sets or removes focus from a specific widget or group
494
+ '
495
+ ' Handles focus state changes, callbacks, and native focus interaction.
496
+ '
497
+ ' @param {dynamic} ref - The target: HID (string) of a FocusItem or Group, or Node ID (string) of a Group
498
+ ' @param {boolean} isFocused - True to set focus, false to remove focus (default: true)
499
+ ' @param {boolean} enableNativeFocus - If true, allows setting native focus on the underlying node (default: false)
500
+ ' @returns {boolean} True if the focus state was successfully changed, false otherwise
501
+ '
502
+ function setFocus(ref as dynamic, isFocused = true as boolean, enableNativeFocus = false as boolean) as boolean
503
+
504
+ ' Resolve reference (HID or ID) to a focusItem item.
505
+ focusItem = invalid ' Initialize target focus item
506
+
507
+ ' Exit if reference is empty or invalid.
508
+ if ref = invalid or ref = "" then return false
509
+
510
+ if m.focusItemStack.has(ref)
511
+ ' Case 1: ref is a valid focusItem HID.
512
+ focusItem = m.focusItemStack.get(ref)
513
+ else
514
+ ' Case 2: ref might be a focusItem node ID.
515
+ focusItem = m.focusItemStack.getByNodeId(ref)
516
+
517
+ if focusItem = invalid
518
+ ' Case 3: ref might be a group HID or group node ID.
519
+ ' Try finding group by HID first, then by Node ID.
520
+ group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
521
+ if group <> invalid
522
+ ' If group found, find its default/entry focus item recursively.
523
+ HID = m.capturingFocus_recursively(group.HID)
524
+ focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
525
+
526
+ ' else: ref is not a known FocusItem HID or Group identifier
527
+ end if
528
+ end if
529
+ end if
530
+
531
+ ' Handle case where the target focus item could not be found or resolved.
532
+ if focusItem = invalid
533
+ focused = m.focusItemStack.get(m.globalFocusHID) ' Check current focus
534
+ #if debug
535
+ ' Log warnings if focus target is not found
536
+ if focused = invalid
537
+ print `[PLUGIN][FOCUS][WARNING] Requested focus target ref: "${ref}" was not found or resolved to a valid FocusItem.`
538
+ if m.globalFocusHID = ""
539
+ ' If global focus is also lost, indicate potential issue.
540
+ print `[PLUGIN][FOCUS][WARNING] Focus lost issue likely. No current focus set. Ensure valid initial focus.`
541
+ else
542
+ print `[PLUGIN][FOCUS][WARNING] Current focus HID: "${m.globalFocusHID}". Ensure target "${ref}" is registered and reachable.`
543
+ end if
544
+ else
545
+ print `[PLUGIN][FOCUS][WARNING] Could not find focus target ref: "${ref}". Current focus remains on HID: "${m.globalFocusHID}", id"${m.globalFocusId}"".`
546
+ end if
547
+ #end if
548
+ return false ' Indicate focus change failed
549
+ end if
550
+
551
+ ' Found a valid focusItem to target
552
+ HID = focusItem.HID
553
+
554
+ ' Exit if already focused/blurred as requested (no change needed).
555
+ if HID = m.globalFocusHID and isFocused = true then return false
556
+ ' Note: Handling blur when already blurred might be needed depending on desired logic, currently allows blurring focused item.
557
+
558
+ ' Cannot focus an invisible item.
559
+ if focusItem.node.visible = false and isFocused = true then return false
560
+
561
+ ' Determine if native focus should be enabled (request or item default)
562
+ enableNativeFocus = enableNativeFocus or focusItem.enableNativeFocus = true
563
+
564
+ ' Prevent focusing a disabled item.
565
+ preventFocusOnDisabled = focusItem.isEnabled = false and isFocused = true
566
+ if preventFocusOnDisabled
567
+ return false ' Indicate focus change failed
568
+ end if
569
+
570
+ ' Handle blurring the previously focused item
571
+ lastFocusChainingGroups = []
572
+ if m.globalFocusHID <> "" ' If something was focused before
573
+ lastFocused = m.focusItemStack.get(m.globalFocusHID)
574
+ if lastFocused <> invalid ' Check if the last focused widget hasn't been destroyed
575
+ ' Blur the previously focused item
576
+ lastFocused.applyFocus(false, enableNativeFocus)
577
+
578
+ ' Record the last focused item within its parent group for potential future use (e.g., returning focus)
579
+ lastFocusChainingGroups = m.findAncestorGroups(m.globalFocusHID)
580
+ if lastFocusChainingGroups.Count() > 0
581
+ parentGroupHID = lastFocusChainingGroups[0]
582
+ if parentGroupHID <> invalid and parentGroupHID <> ""
583
+ group = m.groupStack.get(parentGroupHID)
584
+ if group <> invalid
585
+ group.setLastFocusedHID(m.globalFocusHID)
586
+ end if
587
+ end if
588
+ end if
589
+ end if
590
+ end if
591
+
592
+ ' Apply focus state (focused/blurred) to the target item.
593
+ focusItem.applyFocus(isFocused, enableNativeFocus)
594
+
595
+ ' Update focus state for ancestor groups (blur groups losing focus, focus groups gaining focus)
596
+ focusChainGroups = m.findAncestorGroups(focusItem.HID) ' Groups containing the new focus
597
+ focusChainGroups.append(lastFocusChainingGroups) ' Include groups containing the old focus
598
+ Rotor.Utils.removeRedundantValuesInArray(focusChainGroups) ' Unique list of affected groups
599
+ m.notifyFocusAtAncestorGroups(focusItem.HID, focusChainGroups) ' Notify all relevant groups
600
+
601
+ ' Update the globally tracked focused item.
602
+ m.storeGlobalFocusHID(isFocused ? HID : "", isFocused ? focusItem.id : "")
603
+
604
+ ' Ensure SceneGraph root has focus if native focus wasn't explicitly enabled on the item.
605
+ if enableNativeFocus = false
606
+ globalScope = GetGlobalAA()
607
+ if globalScope.top.isInFocusChain() = false
608
+ globalScope.top.setFocus(true)
609
+ end if
610
+ end if
611
+
612
+ return true
613
+
614
+ end function
615
+
616
+ '
617
+ ' notifyFocusAtAncestorGroups - Applies the correct focus state (in focus chain or not) to a list of group HIDs
618
+ '
619
+ ' @param {string} HID - The HID of the item that ultimately received/lost focus
620
+ ' @param {object} groupHIDs - An roArray of group HIDs to notify
621
+ '
622
+ sub notifyFocusAtAncestorGroups(HID as string, groupHIDs = [] as object)
623
+
624
+ ' Notify all ancestor groups
625
+ if groupHIDs.Count() > 0
626
+ for each groupHID in groupHIDs
627
+
628
+ group = m.groupStack.get(groupHID)
629
+ isInFocusChain = Rotor.Utils.isAncestorHID(groupHID, HID)
630
+ group.applyFocus(isInFocusChain)
631
+
632
+ end for
633
+ end if
634
+ end sub
635
+
636
+ sub notifyLongPressAtAncestorGroups(isLongPress as boolean, key as string, HID as string, groupHIDs = [] as object)
637
+ ' Notify all ancestor groups
638
+ if groupHIDs.Count() > 0
639
+ for each groupHID in groupHIDs
640
+ group = m.groupStack.get(groupHID)
641
+ handled = group.callLongPressHandler(isLongPress, key)
642
+ if handled then exit for
643
+ end for
644
+ end if
645
+ end sub
646
+
647
+ sub delegateLongPressChanged(isLongPress as boolean, key as string)
648
+ focused = m.getFocusedItem()
649
+ handled = focused.callLongPressHandler(isLongPress, key)
650
+ if handled then return
651
+
652
+ focusChainGroups = m.findAncestorGroups(focused.HID)
653
+ m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
654
+ end sub
655
+
656
+ function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object) as string
657
+ if focused.enableSpatialNavigation = false then return ""
658
+ if direction = Rotor.Const.Direction.BACK then return ""
659
+
660
+ ' Remove current focused item from candidates
661
+ index = Rotor.Utils.findInArray(focusItemsHIDlist, focused.HID)
662
+ if index >= 0 then focusItemsHIDlist.delete(index)
663
+
664
+ ' Find closest focusable item in direction
665
+ segments = m.collectSegments(focused, direction, focusItemsHIDlist)
666
+ if segments.Count() > 0
667
+ return m.findClosestSegment(segments, focused.metrics.middlePoint)
668
+ end if
669
+
670
+ return ""
671
+ end function
672
+
673
+ function findClosestSegment(segments as object, middlePoint as object) as string
674
+ distances = []
675
+
676
+ ' Calculate distance from middle point to each segment
677
+ for each HID in segments
678
+ segment = segments[HID]
679
+ distance = m.distanceCalculator.distToSegment(middlePoint, {
680
+ x: segment.x1,
681
+ y: segment.y1
682
+ }, {
683
+ x: segment.x2,
684
+ y: segment.y2
685
+ })
686
+
687
+ distances.push({
688
+ HID: HID,
689
+ distance: distance
690
+ })
691
+ end for
692
+
693
+ ' Find segment with minimum distance
694
+ minDistItem = Rotor.Utils.checkArrayItemsByHandler(distances, "distance", function(a, b) as dynamic
695
+ return a < b
696
+ end function)
697
+
698
+ return minDistItem.HID
699
+ end function
700
+
701
+
702
+ ' Waterfall of fallback's of groups (linked together with defaultFocusId)
703
+ function capturingFocus_recursively(identifier as string, direction = "", ancestorHID = "0" as string) as string
704
+ ' Resolve identifier to a group
705
+ group = m.groupStack.get(identifier)
706
+ if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
707
+ if group = invalid then return ""
708
+
709
+ ' Get fallback identifier for this group
710
+ newHID = group.getFallbackIdentifier()
711
+
712
+ ' Check if we found a FocusItem
713
+ if m.focusItemStack.has(newHID)
714
+ ' Apply spatial enter feature if enabled
715
+ if group.enableSpatialEnter = true and direction <> ""
716
+ focused = m.focusItemStack.get(m.globalFocusHID)
717
+ newSpatialHID = m.spatialNavigation(focused, direction, group.getGroupMembersHIDs())
718
+ if newSpatialHID <> "" then newHID = newSpatialHID
719
+ end if
720
+
721
+ else if newHID <> ""
722
+ ' Try to find as group first, then deep search
723
+ newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
724
+
725
+ ' If still not found, perform deep search in all descendants
726
+ if newHID = ""
727
+ newHID = m.deepSearchFocusItemByNodeId(group.HID, group.getFallbackNodeId())
728
+ end if
729
+ end if
730
+
731
+ ' Prevent capturing by fallback in the same group where original focus was
732
+ if newHID <> "" and m.globalFocusHID <> ""
733
+ currentAncestors = m.findAncestorGroups(m.globalFocusHID)
734
+ newAncestors = m.findAncestorGroups(newHID)
735
+ if currentAncestors.Count() > 0 and newAncestors.Count() > 0
736
+ if currentAncestors[0] = newAncestors[0] then newHID = ""
737
+ end if
738
+ end if
739
+
740
+ return newHID
741
+ end function
742
+
743
+ '
744
+ ' deepSearchFocusItemByNodeId - Deep search for a FocusItem or Group by nodeId within a group hierarchy
745
+ '
746
+ ' @param {string} groupHID - The HID of the group to search within
747
+ ' @param {string} nodeId - The node ID to search for
748
+ ' @returns {string} The HID of the found FocusItem or Group, or empty string if not found
749
+ '
750
+ function deepSearchFocusItemByNodeId(groupHID as string, nodeId as string) as string
751
+ if nodeId = "" then return ""
752
+
753
+ ' Get all descendants of this group (both FocusItems and nested Groups)
754
+ allFocusItems = m.focusItemStack.getAll()
755
+ allGroups = m.groupStack.getAll()
756
+
757
+ ' First, search in direct and nested FocusItems
758
+ for each focusItemHID in allFocusItems
759
+ if Rotor.Utils.isDescendantHID(focusItemHID, groupHID)
760
+ focusItem = m.focusItemStack.get(focusItemHID)
761
+ if focusItem <> invalid and focusItem.id = nodeId
762
+ return focusItemHID
763
+ end if
764
+ end if
765
+ end for
766
+
767
+ ' Second, search in nested Groups (and if found, apply fallback logic on that group)
768
+ for each nestedGroupHID in allGroups
769
+ if Rotor.Utils.isDescendantHID(nestedGroupHID, groupHID) and nestedGroupHID <> groupHID
770
+ nestedGroup = m.groupStack.get(nestedGroupHID)
771
+ if nestedGroup <> invalid and nestedGroup.id = nodeId
772
+ ' Found a matching group - now apply fallback logic on it
773
+ fallbackHID = nestedGroup.getFallbackIdentifier()
774
+ if m.focusItemStack.has(fallbackHID)
775
+ return fallbackHID
776
+ else if fallbackHID <> ""
777
+ ' Recursively resolve the fallback
778
+ return m.capturingFocus_recursively(fallbackHID, "", nestedGroupHID)
779
+ end if
780
+ end if
781
+ end if
782
+ end for
783
+
784
+ return ""
785
+ end function
786
+
787
+ function bubblingFocus(groupHID, direction = "" as string) as dynamic
788
+ newHID = ""
789
+
790
+ ' Build ancestor chain (current group + all ancestors)
791
+ ancestorGroups = m.findAncestorGroups(groupHID)
792
+ ancestorGroups.unshift(groupHID)
793
+ ancestorGroupsCount = ancestorGroups.Count()
794
+ ancestorIndex = 0
795
+
796
+ ' Bubble up through ancestor groups until we find a target or reach the top
797
+ while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
798
+ ' Get next ancestor group
799
+ groupHID = ancestorGroups[ancestorIndex]
800
+ group = m.groupStack.get(groupHID)
801
+
802
+ ' Check group's direction configuration
803
+ nodeId = group.getStaticNodeIdInDirection(direction)
804
+
805
+ if Rotor.Utils.isBoolean(nodeId)
806
+ ' Boolean means focus is explicitly handled
807
+ if nodeId = true
808
+ newHID = true ' Block navigation (exit loop)
809
+ else
810
+ newHID = "" ' Continue bubbling
811
+ end if
812
+ else
813
+ ' String nodeId - try to resolve target
814
+ if nodeId <> ""
815
+ otherGroup = m.groupStack.getByNodeId(nodeId)
816
+ if otherGroup <> invalid
817
+ newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
818
+ end if
819
+ end if
820
+ end if
821
+
822
+ ancestorIndex++
823
+ end while
824
+
825
+ return newHID
826
+ end function
827
+
828
+ ' * KEY EVENT HANDLER
829
+ function onKeyEventHandler(key as string, press as boolean) as object
830
+ ' Check long-press
831
+ if m.enableLongPressFeature = true
832
+ m.checkLongPressState(key, press)
833
+ end if
834
+ ' Prevent any navigation if it is disabled
835
+ #if debug
836
+ if m.enableFocusNavigation = false and press = true then print "[PLUGIN][FOCUS][INFO] Focus navigation is disabled. Call enableFocusNavigation(true) to make it enabled"
837
+ #end if
838
+ if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
839
+ ' Execute action according to key press
840
+ return m.executeNavigationAction(key, press)
841
+ end function
842
+
843
+ function executeNavigationAction(key as string, press as boolean) as object
844
+
845
+ if true = press
846
+
847
+ if -1 < Rotor.Utils.findInArray([
848
+ Rotor.Const.Direction.UP,
849
+ Rotor.Const.Direction.RIGHT,
850
+ Rotor.Const.Direction.DOWN,
851
+ Rotor.Const.Direction.LEFT,
852
+ Rotor.Const.Direction.BACK
853
+ ], key)
854
+
855
+ newHID = ""
856
+ direction = key
857
+
858
+ ' (1) Pick up current focused item
859
+
860
+ focused = m.focusItemStack.get(m.globalFocusHID)
861
+
862
+ if focused = invalid
863
+ #if debug
864
+ print `[PLUGIN][FOCUS][WARNING] Focus lost issue detected. Last known focus id:\"${m.globalFocusHID}\". Please ensure valid focus.`
865
+ #end if
866
+ return m.parseOnKeyEventResult(key, false, false)
867
+ end if
868
+
869
+
870
+ ancestorGroups = m.findAncestorGroups(focused.HID)
871
+ ancestorGroupsCount = ancestorGroups.Count()
872
+
873
+ if ancestorGroupsCount = 0
874
+ allFocusItems = m.focusItemStack.getAll()
875
+ possibleFocusItems = allFocusItems.keys()
876
+ parentGroupHID = ""
877
+ else
878
+ parentGroupHID = ancestorGroups[0]
879
+ group = m.groupStack.get(parentGroupHID)
880
+ possibleFocusItems = group.getGroupMembersHIDs()
881
+ end if
882
+
883
+ ' (2) Try static direction, defined on the focusItem, among possible focusItems
884
+ nodeId = focused.getStaticNodeIdInDirection(direction) ' Note that this is a nodeId
885
+
886
+ if Rotor.Utils.isBoolean(nodeId) and nodeId = true
887
+ ' It means that focus is handled, and no need further action by plugin.
888
+ return m.parseOnKeyEventResult(key, true, false)
889
+ end if
890
+
891
+ if nodeId <> ""
892
+ newHID = m.focusItemStack.convertNodeIdToHID(nodeId, possibleFocusItems)
893
+ end if
894
+
895
+ if newHID = ""
896
+ ' (3) Try spatial navigation in direction, among possible focusItems
897
+ ' all = m.focusItemStack.getAll()
898
+ ' allKeys = all.Keys()
899
+ newHID = m.spatialNavigation(focused, direction, possibleFocusItems)
900
+ end if
901
+
902
+ ' (4) Check if found group. FocusItem can not point out of group.
903
+ if newHID = "" and ancestorGroupsCount > 0 ' (5/2) If this focused has parent group, lets try bubbling focus on ancestors (groups)
904
+ newHID = m.bubblingFocus(parentGroupHID, direction)
905
+ if Rotor.Utils.isBoolean(newHID)
906
+ if newHID = true
907
+ ' It means that focus is handled, and no need further action by plugin.
908
+ return m.parseOnKeyEventResult(key, true, false)
909
+ else
910
+ newHID = ""
911
+ end if
912
+ end if
913
+ end if
914
+
915
+ handled = m.setFocus(newHID)
916
+ return m.parseOnKeyEventResult(key, handled, false)
917
+
918
+ else if key = "OK"
919
+
920
+ return m.parseOnKeyEventResult(key, true, true)
921
+
922
+ end if
923
+ end if
924
+
925
+ return m.parseOnKeyEventResult(key, false, false)
926
+
927
+ end function
928
+
929
+ function parseOnKeyEventResult(key as string, handled as boolean, isSelected as boolean) as object
930
+ result = {
931
+ handled: handled,
932
+ key: key
933
+ }
934
+ if m.globalFocusHID <> "" and handled = true
935
+ focusItem = m.focusItemStack.get(m.globalFocusHID)
936
+ widget = m.widgetTree.get(focusItem.HID)
937
+ ' viewModelState = Rotor.Utils.deepCopy(widget.viewModelState)
938
+ result.widget = widget
939
+ if isSelected
940
+ result.isSelected = isSelected
941
+ focusItem.callOnSelectedFnOnWidget()
942
+ end if
943
+ end if
944
+ return result
945
+ end function
946
+
947
+ sub checkLongPressState(key as string, press as boolean)
948
+ m.longPressTimer.control = "stop"
949
+ if press = true
950
+ if m.isLongPress = false
951
+ m.longPressKey = key
952
+ m.longPressTimer.control = "start"
953
+ end if
954
+ else
955
+ wasLongPress = m.isLongPress = true
956
+ lastKey = m.longPressKey
957
+ m.isLongPress = false
958
+ m.longPressKey = ""
959
+ if wasLongPress
960
+ m.delegateLongPressChanged(false, lastKey)
961
+ end if
962
+ end if
963
+ end sub
964
+
965
+ function proceedLongPress() as object
966
+ return m.executeNavigationAction(m.longPressKey, true)
967
+ end function
968
+
969
+ ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
970
+ function collectSegments(focused as object, direction as string, focusItemsHIDlist as object) as object
971
+ focused.refreshBounding()
972
+
973
+ refSegmentTop = focused.metrics.segments[Rotor.Const.Segment.TOP]
974
+ refSegmentRight = focused.metrics.segments[Rotor.Const.Segment.RIGHT]
975
+ referencePoint = { x: (refSegmentTop.x1 + refSegmentRight.x2) / 2, y: (refSegmentTop.y1 + refSegmentRight.y2) / 2 }
976
+
977
+ validators = {
978
+
979
+ "left": function(referencePoint as object, segments as object) as object
980
+ right = segments[Rotor.Const.Segment.RIGHT]
981
+ ' stop
982
+ return right.x1 <= referencePoint.x ? { isValid: true, segment: right } : { isValid: false }
983
+ end function,
984
+
985
+ "up": function(referencePoint as object, segments as object) as object
986
+ bottom = segments[Rotor.Const.Segment.BOTTOM]
987
+ ' stop
988
+ return bottom.y1 <= referencePoint.y ? { isValid: true, segment: bottom } : { isValid: false }
989
+ end function,
990
+
991
+ "right": function(referencePoint as object, segments as object) as object
992
+ left = segments[Rotor.Const.Segment.LEFT]
993
+ ' stop
994
+ return left.x1 >= referencePoint.x ? { isValid: true, segment: left } : { isValid: false }
995
+ end function,
996
+
997
+ "down": function(referencePoint as object, segments as object) as object
998
+ top = segments[Rotor.Const.Segment.TOP]
999
+ ' stop
1000
+ return top.y1 >= referencePoint.y ? { isValid: true, segment: top } : { isValid: false }
1001
+ end function
1002
+ }
1003
+ segments = {}
1004
+ validator = validators[direction]
1005
+ for each HID in focusItemsHIDlist
1006
+ if HID <> focused.HID
1007
+ focusItem = m.focusItemStack.get(HID)
1008
+ focusItem.refreshBounding()
1009
+ result = validator(referencePoint, focusItem.metrics.segments)
1010
+ if result.isValid
1011
+ segments[HID] = result.segment
1012
+ end if
1013
+ end if
1014
+ end for
1015
+
1016
+ return segments
1017
+ end function
1018
+
1019
+ sub destroy()
1020
+ ' Remove all groups
1021
+ for each HID in m.groupStack.getAll()
1022
+ m.groupStack.remove(HID)
1023
+ end for
1024
+ ' Remove all focus items
1025
+ for each HID in m.focusItemStack.getAll()
1026
+ m.focusItemStack.remove(HID)
1027
+ end for
1028
+ m.longPressTimer.unobserveFieldScoped("fire")
1029
+ m.longPressTimer = invalid
1030
+ m.widgetTree = invalid
1031
+ end sub
1032
+
1033
+ end class
1034
+
1035
+ namespace FocusPluginHelper
1036
+
1037
+ class BaseEntryStack extends Rotor.BaseStack
1038
+
1039
+ function getByNodeId(nodeId as string, ancestorHID = "0" as string) as object
1040
+ if ancestorHID <> "0"
1041
+ filteredStack = {}
1042
+ for each HID in m.stack
1043
+ if Rotor.Utils.isDescendantHID(HID, ancestorHID)
1044
+ filteredStack[HID] = m.get(HID)
1045
+ end if
1046
+ end for
1047
+ else
1048
+ filteredStack = m.stack
1049
+ end if
1050
+ HID = Rotor.Utils.findInAArrayByKey(filteredStack, "id", nodeId)
1051
+ return HID <> "" ? m.get(HID) : invalid
1052
+ end function
1053
+
1054
+ override sub remove(HID as string)
1055
+ item = m.get(HID)
1056
+ item.destroy()
1057
+ super.remove(HID)
1058
+ end sub
1059
+
1060
+ end class
1061
+
1062
+ class GroupStack extends BaseEntryStack
1063
+
1064
+ function convertNodeIdToHID(nodeId as string, possibleGroups as object) as string
1065
+ foundHID = ""
1066
+ for each HID in possibleGroups
1067
+ group = m.get(HID)
1068
+ if group.id = nodeId
1069
+ foundHID = group.HID
1070
+ exit for
1071
+ end if
1072
+ end for
1073
+ return foundHID
1074
+ end function
1075
+
1076
+ end class
1077
+
1078
+
1079
+ class FocusItemStack extends BaseEntryStack
1080
+
1081
+ function convertNodeIdToHID(nodeId as string, possibleFocusItems as object) as string
1082
+ foundHID = ""
1083
+ for each HID in possibleFocusItems
1084
+ focusItem = m.get(HID)
1085
+ if focusItem.id = nodeId
1086
+ foundHID = focusItem.HID
1087
+ exit for
1088
+ end if
1089
+ end for
1090
+ return foundHID
1091
+ end function
1092
+
1093
+ function hasEnabled(HID as string) as boolean
1094
+ if m.has(HID)
1095
+ focusItem = m.get(HID)
1096
+ return focusItem.isEnabled
1097
+ else
1098
+ return false
1099
+ end if
1100
+ end function
1101
+
1102
+ end class
1103
+
1104
+ class BaseFocusConfig
1105
+
1106
+ autoSetIsFocusedOnContext as boolean
1107
+ staticDirection as object
1108
+
1109
+ sub new (config as object)
1110
+
1111
+ m.HID = config.HID
1112
+ m.id = config.id
1113
+
1114
+ m.widget = config.widget
1115
+ m.node = m.widget.node
1116
+ m.isFocused = config.isFocused ?? false
1117
+
1118
+ m.autoSetIsFocusedOnContext = config.autoSetIsFocusedOnContext ?? true
1119
+
1120
+ m.isEnabled = config.isEnabled ?? true
1121
+ m.staticDirection = {}
1122
+ m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1123
+ m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
1124
+ m.staticDirection[Rotor.Const.Direction.DOWN] = config.down ?? ""
1125
+ m.staticDirection[Rotor.Const.Direction.LEFT] = config.left ?? ""
1126
+ m.staticDirection[Rotor.Const.Direction.BACK] = config.back ?? ""
1127
+
1128
+ m.onFocusChanged = config.onFocusChanged
1129
+ m.longPressHandler = config.longPressHandler
1130
+ m.onFocus = config.onFocus
1131
+
1132
+ Rotor.Utils.setCustomFields(m.node, { "isFocused": false }, true, true)
1133
+
1134
+ ' convenience (usually this is used on viewModelState)
1135
+ if false = m.widget.viewModelState.DoesExist("isFocused") and true = m.autoSetIsFocusedOnContext
1136
+ m.widget.viewModelState.isFocused = false ' as default
1137
+ end if
1138
+
1139
+ end sub
1140
+
1141
+
1142
+ HID as string
1143
+ id as string
1144
+ idByKeys as object
1145
+ isEnabled as boolean
1146
+ isFocused as boolean
1147
+ onFocusChanged as dynamic
1148
+ onFocus as dynamic
1149
+ longPressHandler as dynamic
1150
+ node as object
1151
+ widget as object
1152
+
1153
+ function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1154
+ direction = m.staticDirection[direction]
1155
+ if Rotor.Utils.isFunction(direction)
1156
+ return Rotor.Utils.callbackScoped(direction, m.widget) ?? ""
1157
+ else
1158
+ return direction ?? ""
1159
+ end if
1160
+ end function
1161
+
1162
+ sub callOnFocusedFnOnWidget(isFocused as boolean)
1163
+ Rotor.Utils.callbackScoped(m.onFocusChanged, m.widget, isFocused)
1164
+ if true = isFocused
1165
+ Rotor.Utils.callbackScoped(m.onFocus, m.widget)
1166
+ end if
1167
+ end sub
1168
+
1169
+ function callLongPressHandler(isLongPress as boolean, key as string) as boolean
1170
+ if Rotor.Utils.isFunction(m.longPressHandler)
1171
+ return Rotor.Utils.callbackScoped(m.longPressHandler, m.widget, isLongPress, key)
1172
+ else
1173
+ return false
1174
+ end if
1175
+ end function
1176
+
1177
+ sub destroy()
1178
+ m.widget = invalid
1179
+ m.node = invalid
1180
+ m.onFocusChanged = invalid
1181
+ m.longPressHandler = invalid
1182
+ end sub
1183
+
1184
+ end class
1185
+
1186
+ class GroupClass extends BaseFocusConfig
1187
+ ' Note: Spatial navigation is supported within group, there is no spatial navigation between groups
1188
+ ' If you want to focus out to another group, you need to config a direction prop.
1189
+ ' You can set a groupId or any focusItem widgetId.
1190
+ ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1191
+ ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1192
+
1193
+ sub new (config as object)
1194
+ super(config)
1195
+ m.defaultFocusId = config.defaultFocusId ?? ""
1196
+ m.lastFocusedHID = config.lastFocusedHID ?? ""
1197
+ m.enableSpatialEnter = config.enableSpatialEnter ?? false
1198
+ end sub
1199
+
1200
+ defaultFocusId as string
1201
+ lastFocusedHID as string
1202
+ enableSpatialEnter as boolean
1203
+ focusItemsRef as object
1204
+ groupsRef as object
1205
+
1206
+ isFocusItem = false
1207
+ isGroup = true
1208
+
1209
+ sub setLastFocusedHID(lastFocusedHID as string)
1210
+ m.lastFocusedHID = lastFocusedHID
1211
+ end sub
1212
+
1213
+ function getGroupMembersHIDs()
1214
+ ' Collect all focusItems that are descendants of this group
1215
+ ' Exclude items that belong to nested sub-groups
1216
+ focusItems = m.focusItemsRef.getAll()
1217
+ groups = m.groupsRef.getAll()
1218
+ HIDlen = Len(m.HID)
1219
+ collection = []
1220
+ groupsKeys = groups.keys()
1221
+ groupsCount = groups.Count()
1222
+
1223
+ for each focusItemHID in focusItems
1224
+ ' Check if focusItem is a descendant of this group
1225
+ isDescendant = Left(focusItemHID, HIDlen) = m.HID
1226
+ if isDescendant
1227
+ ' Check if focusItem belongs to a nested sub-group
1228
+ shouldExclude = false
1229
+ otherGroupIndex = 0
1230
+ while shouldExclude = false and otherGroupIndex < groupsCount
1231
+ otherGroupHID = groupsKeys[otherGroupIndex]
1232
+ otherGroupHIDlen = Len(otherGroupHID)
1233
+ ' Exclude if belongs to deeper nested group
1234
+ shouldExclude = Left(focusItemHID, otherGroupHIDlen) = otherGroupHID and otherGroupHIDlen > HIDlen
1235
+ otherGroupIndex++
1236
+ end while
1237
+
1238
+ if not shouldExclude then collection.push(focusItemHID)
1239
+ end if
1240
+ end for
1241
+
1242
+ return collection
1243
+ end function
1244
+
1245
+ '
1246
+ ' getFallbackNodeId - Returns the nodeId to use for fallback (defaultFocusId or lastFocusedHID)
1247
+ '
1248
+ ' @returns {string} The nodeId to use for fallback, or empty string if none
1249
+ '
1250
+ function getFallbackNodeId() as string
1251
+ if m.lastFocusedHID <> ""
1252
+ ' Note: lastFocusedHID is already a HID, not a nodeId, so we need to get the nodeId
1253
+ lastFocusedItem = m.focusItemsRef.get(m.lastFocusedHID)
1254
+ if lastFocusedItem <> invalid
1255
+ return lastFocusedItem.id
1256
+ end if
1257
+ end if
1258
+
1259
+ if Rotor.Utils.isFunction(m.defaultFocusId)
1260
+ return Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
1261
+ else
1262
+ return m.defaultFocusId
1263
+ end if
1264
+ end function
1265
+
1266
+ function getFallbackIdentifier() as string
1267
+ HID = ""
1268
+ if m.lastFocusedHID <> ""
1269
+ return m.lastFocusedHID
1270
+ else
1271
+ if Rotor.Utils.isFunction(m.defaultFocusId)
1272
+ defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
1273
+ else
1274
+ defaultFocusId = m.defaultFocusId
1275
+ end if
1276
+
1277
+ if defaultFocusId <> ""
1278
+ focusItemsHIDlist = m.getGroupMembersHIDs()
1279
+ if focusItemsHIDlist.Count() > 0
1280
+
1281
+ ' Try find valid HID in focusItems by node id
1282
+ focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1283
+ if focusItemHID <> ""
1284
+ HID = focusItemHID
1285
+ end if
1286
+
1287
+ else
1288
+
1289
+ return defaultFocusId
1290
+
1291
+ end if
1292
+ end if
1293
+
1294
+ end if
1295
+
1296
+ return HID
1297
+ end function
1298
+
1299
+ function findHIDinFocusItemsByNodeId(nodeId as string, focusItemsHIDlist as object) as string
1300
+ HID = ""
1301
+ for each HID in focusItemsHIDlist
1302
+ focusItem = m.focusItemsRef.get(HID)
1303
+ if focusItem <> invalid and focusItem.id = nodeId
1304
+ HID = focusItem.HID
1305
+ exit for
1306
+ end if
1307
+ end for
1308
+ return HID
1309
+ end function
1310
+
1311
+ sub applyFocus(isFocused as boolean)
1312
+ if m.isFocused = isFocused then return
1313
+
1314
+ m.isFocused = isFocused
1315
+
1316
+ if m.autoSetIsFocusedOnContext
1317
+ m.widget.viewModelState.isInFocusChain = isFocused
1318
+ end if
1319
+ m.node.setField("isFocused", isFocused)
1320
+ m.callOnFocusedFnOnWidget(isFocused)
1321
+ end sub
1322
+
1323
+ override sub destroy()
1324
+ super.destroy()
1325
+ m.focusItemsRef = invalid
1326
+ m.groupsRef = invalid
1327
+ end sub
1328
+
1329
+
1330
+
1331
+ end class
1332
+
1333
+ class FocusItemClass extends BaseFocusConfig
1334
+
1335
+ sub new (config as object)
1336
+ super(config)
1337
+
1338
+ m.onSelected = config.onSelected ?? ""
1339
+ m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
1340
+ m.enableNativeFocus = config.enableNativeFocus ?? false
1341
+ end sub
1342
+
1343
+ ' You can set a groupId or any focusItem widgetId.
1344
+ ' > Point to a groupId: focus will be set to defaultFocusId or lastFocusedHID if available
1345
+ ' > Point to a widgetId: focus will be set to widgetId (and relevant group will be activated)
1346
+
1347
+ ' key as string
1348
+ isFocusItem = true
1349
+ isGroup = false
1350
+ enableNativeFocus as boolean
1351
+ enableSpatialNavigation as boolean
1352
+ onSelected as dynamic
1353
+
1354
+ private metrics = {
1355
+ segments: {}
1356
+ }
1357
+ private bounding as object
1358
+
1359
+
1360
+ sub refreshBounding()
1361
+ b = m.node.sceneBoundingRect()
1362
+ rotation = m.node.rotation
1363
+
1364
+ ' If both bounding x and y are zero, then we assume that inheritParentTransform = false
1365
+ ' That is why we can use translation without knowing the value of inheritParentTransform
1366
+ ' If bounding x or y are not zero, then bounding will include the node's translation
1367
+ if rotation = 0
1368
+ if b.y = 0 and b.x = 0
1369
+ t = m.node.translation
1370
+ b.x += t[0]
1371
+ b.y += t[1]
1372
+ end if
1373
+
1374
+ m.metrics.append(b)
1375
+ m.metrics.segments[Rotor.Const.Segment.LEFT] = {
1376
+ x1: b.x, y1: b.y,
1377
+ x2: b.x, y2: b.y + b.height
1378
+ }
1379
+ m.metrics.segments[Rotor.Const.Segment.TOP] = {
1380
+ x1: b.x, y1: b.y,
1381
+ x2: b.x + b.width, y2: b.y
1382
+ }
1383
+ m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
1384
+ x1: b.x + b.width, y1: b.y,
1385
+ x2: b.x + b.width, y2: b.y + b.height
1386
+ }
1387
+ m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
1388
+ x1: b.x, y1: b.y + b.height,
1389
+ x2: b.x + b.width, y2: b.y + b.height
1390
+ }
1391
+ m.metrics.middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
1392
+ else
1393
+ scaleRotateCenter = m.node.scaleRotateCenter
1394
+ dims = m.node.localBoundingRect() ' We need this to get proper (rotated value of rotated x and y)
1395
+ if b.y = 0 and b.x = 0
1396
+ t = m.node.translation
1397
+ b.x += t[0]
1398
+ b.y += t[1]
1399
+ end if
1400
+ b.width = dims.width
1401
+ b.height = dims.height
1402
+ m.metrics.append(b)
1403
+
1404
+ ' Calculate rotated segments
1405
+ segmentLEFT = { x1: b.x, y1: b.y, x2: b.x, y2: b.y + b.height }
1406
+ rotatedSegment = Rotor.Utils.rotateSegment(segmentLEFT.x1, segmentLEFT.y1, segmentLEFT.x2, segmentLEFT.y2, rotation, scaleRotateCenter)
1407
+ m.metrics.segments[Rotor.Const.Segment.LEFT] = rotatedSegment
1408
+
1409
+ segmentTOP = { x1: b.x, y1: b.y, x2: b.x + b.width, y2: b.y }
1410
+ rotatedSegment = Rotor.Utils.rotateSegment(segmentTOP.x1, segmentTOP.y1, segmentTOP.x2, segmentTOP.y2, rotation, scaleRotateCenter)
1411
+ m.metrics.segments[Rotor.Const.Segment.TOP] = rotatedSegment
1412
+
1413
+ segmentRIGHT = { x1: b.x + b.width, y1: b.y, x2: b.x + b.width, y2: b.y + b.height }
1414
+ rotatedSegment = Rotor.Utils.rotateSegment(segmentRIGHT.x1, segmentRIGHT.y1, segmentRIGHT.x2, segmentRIGHT.y2, rotation, scaleRotateCenter)
1415
+ m.metrics.segments[Rotor.Const.Segment.RIGHT] = rotatedSegment
1416
+
1417
+ segmentBOTTOM = { x1: b.x, y1: b.y + b.height, x2: b.x + b.width, y2: b.y + b.height }
1418
+ rotatedSegment = Rotor.Utils.rotateSegment(segmentBOTTOM.x1, segmentBOTTOM.y1, segmentBOTTOM.x2, segmentBOTTOM.y2, rotation, scaleRotateCenter)
1419
+ m.metrics.segments[Rotor.Const.Segment.BOTTOM] = rotatedSegment
1420
+
1421
+ ' Calculate rotated middle point
1422
+ middlePoint = { x: b.x + b.width / 2, y: b.y + b.height / 2 }
1423
+ rotatedMiddlePoint = Rotor.Utils.rotateSegment(middlePoint.x, middlePoint.y, 0, 0, rotation, scaleRotateCenter)
1424
+ m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
1425
+
1426
+ end if
1427
+ end sub
1428
+
1429
+ override sub destroy()
1430
+ m.onSelected = invalid
1431
+ m.metrics.segments.Clear()
1432
+ super.destroy()
1433
+ end sub
1434
+
1435
+ sub applyFocus(isFocused as boolean, enableNativeFocus = false as boolean)
1436
+ if m.isFocused = isFocused then return
1437
+
1438
+ m.isFocused = isFocused
1439
+
1440
+ if m.autoSetIsFocusedOnContext
1441
+ m.widget.viewModelState.isFocused = isFocused
1442
+ end if
1443
+
1444
+ m.node.setField("isFocused", isFocused)
1445
+
1446
+ if enableNativeFocus or m.enableNativeFocus
1447
+ m.node.setFocus(isFocused)
1448
+ end if
1449
+
1450
+ m.callOnFocusedFnOnWidget(isFocused)
1451
+
1452
+ end sub
1453
+
1454
+ sub callOnSelectedFnOnWidget()
1455
+ Rotor.Utils.callbackScoped(m.onSelected, m.widget)
1456
+ end sub
1457
+
1458
+ end class
1459
+
1460
+ class ClosestSegmentToPointCalculatorClass
1461
+
1462
+ ' Translated from js; source: https://stackoverflow.com/a/6853926/16164491 (author:Joshua)
1463
+ function pDistance(x, y, x1, y1, x2, y2)
1464
+
1465
+ A = x - x1
1466
+ B = y - y1
1467
+ C = x2 - x1
1468
+ D = y2 - y1
1469
+
1470
+ dot = A * C + B * D
1471
+ len_sq = C * C + D * D
1472
+ param = -1
1473
+ if len_sq <> 0
1474
+ param = dot / len_sq
1475
+ end if
1476
+
1477
+ xx = 0
1478
+ yy = 0
1479
+
1480
+ if param < 0
1481
+ xx = x1
1482
+ yy = y1
1483
+ else if param > 1
1484
+ xx = x2
1485
+ yy = y2
1486
+ else
1487
+ xx = x1 + param * C
1488
+ yy = y1 + param * D
1489
+ end if
1490
+
1491
+ dx = x - xx
1492
+ dy = y - yy
1493
+ return dx * dx + dy * dy
1494
+ end function
1495
+
1496
+ function distToSegment(p as object, s1 as object, s2 as object)
1497
+ return m.pDistance(p.x, p.y, s1.x, s1.y, s2.x, s2.y)
1498
+ end function
1499
+
1500
+ end class
1501
+
1502
+ end namespace
1503
+
1504
+ namespace FocusPluginHelper
1505
+
1506
+ sub longPressObserverCallback(msg)
1507
+ extraInfo = msg.GetInfo()
1508
+
1509
+ pluginKey = extraInfo["pluginKey"]
1510
+
1511
+ globalScope = GetGlobalAA()
1512
+ frameworkInstance = globalScope.rotor_framework_helper.frameworkInstance
1513
+ plugin = frameworkInstance.plugins[pluginKey]
1514
+ plugin.isLongPress = true
1515
+ ' plugin.longPressStartHID = plugin.globalFocusHID
1516
+ plugin.delegateLongPressChanged(true, plugin.longPressKey)
1517
+
1518
+ end sub
1519
+
1520
+ end namespace
1521
+
1522
+ end namespace