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.
- package/LICENSE.md +21 -0
- package/README.md +120 -0
- package/package.json +59 -0
- package/src/source/RotorFramework.bs +654 -0
- package/src/source/RotorFrameworkTask.bs +278 -0
- package/src/source/base/BaseModel.bs +52 -0
- package/src/source/base/BasePlugin.bs +48 -0
- package/src/source/base/BaseReducer.bs +184 -0
- package/src/source/base/BaseStack.bs +92 -0
- package/src/source/base/BaseViewModel.bs +124 -0
- package/src/source/base/BaseWidget.bs +104 -0
- package/src/source/base/DispatcherCreator.bs +193 -0
- package/src/source/base/DispatcherExternal.bs +260 -0
- package/src/source/base/ListenerForDispatchers.bs +246 -0
- package/src/source/engine/Constants.bs +74 -0
- package/src/source/engine/animator/Animator.bs +334 -0
- package/src/source/engine/builder/Builder.bs +213 -0
- package/src/source/engine/builder/NodePool.bs +236 -0
- package/src/source/engine/builder/PluginAdapter.bs +139 -0
- package/src/source/engine/builder/PostProcessor.bs +331 -0
- package/src/source/engine/builder/Processor.bs +156 -0
- package/src/source/engine/builder/Tree.bs +278 -0
- package/src/source/engine/builder/TreeBase.bs +313 -0
- package/src/source/engine/builder/WidgetCreate.bs +322 -0
- package/src/source/engine/builder/WidgetRemove.bs +72 -0
- package/src/source/engine/builder/WidgetUpdate.bs +113 -0
- package/src/source/engine/providers/Dispatcher.bs +72 -0
- package/src/source/engine/providers/DispatcherProvider.bs +95 -0
- package/src/source/engine/services/I18n.bs +169 -0
- package/src/source/libs/animate/Animate.bs +753 -0
- package/src/source/libs/animate/LICENSE.txt +21 -0
- package/src/source/plugins/DispatcherProviderPlugin.bs +127 -0
- package/src/source/plugins/FieldsPlugin.bs +180 -0
- package/src/source/plugins/FocusPlugin.bs +1522 -0
- package/src/source/plugins/FontStylePlugin.bs +159 -0
- package/src/source/plugins/ObserverPlugin.bs +548 -0
- package/src/source/utils/ArrayUtils.bs +495 -0
- package/src/source/utils/GeneralUtils.bs +181 -0
- 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
|