rotor-framework 0.7.6 β 0.7.7
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 +1 -1
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/source/RotorFramework.bs +3 -3
- package/src/source/RotorFrameworkTask.bs +3 -3
- package/src/source/base/BaseViewModel.bs +5 -3
- package/src/source/engine/builder/WidgetCreate.bs +22 -14
- package/src/source/engine/services/Tts.bs +20 -20
- package/src/source/plugins/FocusPlugin.bs +246 -120
package/LICENSE
CHANGED
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
|
|
176
176
|
END OF TERMS AND CONDITIONS
|
|
177
177
|
|
|
178
|
-
Copyright 2025 Balazs Molnar
|
|
178
|
+
Copyright Β© 2025-2026 Balazs Molnar
|
|
179
179
|
|
|
180
180
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
181
181
|
you may not use this file except in compliance with the License.
|
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ You can find [π±](./docs/ai/readme.opt.yaml) symbols in all documentation page
|
|
|
96
96
|
|
|
97
97
|
## π Learn More
|
|
98
98
|
|
|
99
|
-

|
|
100
100
|
|
|
101
101
|
### Framework Core
|
|
102
102
|
|
|
@@ -185,6 +185,6 @@ This helps others discover the project and supports the open source community. T
|
|
|
185
185
|
|
|
186
186
|
---
|
|
187
187
|
|
|
188
|
-
**Copyright 2025 Balazs Molnar**
|
|
188
|
+
**Copyright Β© 2025-2026 Balazs Molnar**
|
|
189
189
|
|
|
190
190
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rotor-framework",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
4
4
|
"description": "Roku toolkit library providing a ViewBuilder, full UI lifecycle with focus handling and many core features, plus MVI-based state management.",
|
|
5
5
|
"author": "BalΓ‘zs MolnΓ‘r",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
' βββββββ ββ β ββ βββββββ βββββββββββββββββ βββββββββ ββββ βββββββββββ
|
|
5
5
|
' ββ βββββββ β βββββββ ββ ββ ββ ββββ ββββ βββββββββββββββββββ ββββ ββ
|
|
6
6
|
' Rotor Frameworkβ’
|
|
7
|
-
' Version 0.7.
|
|
8
|
-
' Β© 2025 BalΓ‘zs MolnΓ‘r β Apache License 2.0
|
|
7
|
+
' Version 0.7.7
|
|
8
|
+
' Β© 2025-2026 BalΓ‘zs MolnΓ‘r β Apache License 2.0
|
|
9
9
|
' =========================================================================
|
|
10
10
|
|
|
11
11
|
' constants
|
|
@@ -86,7 +86,7 @@ namespace Rotor
|
|
|
86
86
|
class Framework
|
|
87
87
|
|
|
88
88
|
name = "Rotor Framework"
|
|
89
|
-
version = "0.7.
|
|
89
|
+
version = "0.7.7"
|
|
90
90
|
|
|
91
91
|
config = {
|
|
92
92
|
tasks: invalid, ' @array (optional)
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
' βββββββ ββ β ββ βββββββ βββββββββββββββββ βββββββββ ββββ βββββββββββ
|
|
5
5
|
' ββ βββββββ β βββββββ ββ ββ ββ ββββ ββββ βββββββββββββββββββ ββββ ββ
|
|
6
6
|
' Rotor Frameworkβ’
|
|
7
|
-
' Version 0.7.
|
|
8
|
-
' Β© 2025 BalΓ‘zs MolnΓ‘r β Apache License 2.0
|
|
7
|
+
' Version 0.7.7
|
|
8
|
+
' Β© 2025-2026 BalΓ‘zs MolnΓ‘r β Apache License 2.0
|
|
9
9
|
' =========================================================================
|
|
10
10
|
|
|
11
11
|
' constants
|
|
@@ -70,7 +70,7 @@ namespace Rotor
|
|
|
70
70
|
class FrameworkTask
|
|
71
71
|
|
|
72
72
|
name = "Rotor Framework"
|
|
73
|
-
version = "0.7.
|
|
73
|
+
version = "0.7.7"
|
|
74
74
|
|
|
75
75
|
config = {
|
|
76
76
|
tasks: invalid, ' optional
|
|
@@ -21,10 +21,10 @@ namespace Rotor
|
|
|
21
21
|
' MEMBER VARIABLES
|
|
22
22
|
' =============================================================
|
|
23
23
|
|
|
24
|
-
isViewModel = true
|
|
24
|
+
isViewModel = true ' Identifies this as a ViewModel
|
|
25
25
|
|
|
26
|
-
viewModelState = {}
|
|
27
|
-
props = {}
|
|
26
|
+
viewModelState = {} ' Mutable state shared across ViewModel's widgets
|
|
27
|
+
props = {} ' Immutable props shared across ViewModel's widgets
|
|
28
28
|
|
|
29
29
|
' =============================================================
|
|
30
30
|
' PROPS MANAGEMENT
|
|
@@ -82,6 +82,8 @@ namespace Rotor
|
|
|
82
82
|
' Called automatically when setProps() is invoked.
|
|
83
83
|
'
|
|
84
84
|
sub onUpdateView()
|
|
85
|
+
' Re-render template
|
|
86
|
+
m.render()
|
|
85
87
|
end sub
|
|
86
88
|
|
|
87
89
|
' ---------------------------------------------------------------------
|
|
@@ -91,26 +91,34 @@ namespace Rotor.ViewBuilder
|
|
|
91
91
|
return m.getFrameworkInstance().builder.widgetTree.getSubtreeClone(searchPattern, configIncludeFilter, m.parentHID)
|
|
92
92
|
end function
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
' Get i18n service
|
|
95
95
|
widget.i18n = function() as object
|
|
96
96
|
return m.getFrameworkInstance().i18nService
|
|
97
97
|
end function
|
|
98
98
|
|
|
99
99
|
' render - Renders widget updates (self, descendants, or children) *'
|
|
100
100
|
widget.render = sub(payloads as dynamic, params = {} as object)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
101
|
+
if payloads = invalid and m.isViewModel = true
|
|
102
|
+
' Self update (only for viewModels)
|
|
103
|
+
payloads = m.template()
|
|
104
|
+
' Self update
|
|
105
|
+
payloads.id = m.id
|
|
106
|
+
payloads.HID = m.HID
|
|
107
|
+
else
|
|
108
|
+
for each payload in Rotor.Utils.ensureArray(payloads)
|
|
109
|
+
if payload.DoesExist("id") = false
|
|
110
|
+
' Self update
|
|
111
|
+
payload.id = m.id
|
|
112
|
+
payload.HID = m.HID
|
|
113
|
+
else if payload.id <> m.id
|
|
114
|
+
' Update descendants starting from this widget
|
|
115
|
+
payload.parentHID = m.HID
|
|
116
|
+
else
|
|
117
|
+
' Update descendants starting from parent widget
|
|
118
|
+
payload.parentHID = m.parentHID
|
|
119
|
+
end if
|
|
120
|
+
end for
|
|
121
|
+
end if
|
|
114
122
|
if Rotor.Utils.isValid(params.callback) then params.callbackScope = m
|
|
115
123
|
m.getFrameworkInstance().builder.render(payloads, params)
|
|
116
124
|
end sub
|
|
@@ -117,7 +117,7 @@ namespace Rotor.ViewBuilder
|
|
|
117
117
|
#if not unittest
|
|
118
118
|
if m.audioGuide = invalid
|
|
119
119
|
#if debug
|
|
120
|
-
? "[TTS_SERVICE][ERROR] Failed to create roAudioGuide instance"
|
|
120
|
+
' ? "[TTS_SERVICE][ERROR] Failed to create roAudioGuide instance"
|
|
121
121
|
#end if
|
|
122
122
|
m.isEnabled = false
|
|
123
123
|
return
|
|
@@ -132,13 +132,13 @@ namespace Rotor.ViewBuilder
|
|
|
132
132
|
m.timerNode.observeFieldScoped("fire", "Rotor_ViewBuilder_ttsDebounceCallback")
|
|
133
133
|
' Note: Timer is NOT started here, only when pending speech is added
|
|
134
134
|
#if debug
|
|
135
|
-
? "[TTS_SERVICE][INFO] Threshold timer created (one-shot, "; m.debounceDelay; "ms)"
|
|
135
|
+
' ? "[TTS_SERVICE][INFO] Threshold timer created (one-shot, "; m.debounceDelay; "ms)"
|
|
136
136
|
#end if
|
|
137
137
|
|
|
138
138
|
' Check device AudioGuide status dynamically
|
|
139
139
|
if not m.getIsDeviceAudioGuideEnabled()
|
|
140
140
|
#if debug
|
|
141
|
-
? "[TTS_SERVICE][INFO] Device AudioGuide is disabled, TTS service disabled"
|
|
141
|
+
' ? "[TTS_SERVICE][INFO] Device AudioGuide is disabled, TTS service disabled"
|
|
142
142
|
#end if
|
|
143
143
|
m.isEnabled = false
|
|
144
144
|
return
|
|
@@ -177,7 +177,7 @@ namespace Rotor.ViewBuilder
|
|
|
177
177
|
if Rotor.Utils.isString(onceKey)
|
|
178
178
|
if m.skipCache.DoesExist(onceKey)
|
|
179
179
|
#if debug
|
|
180
|
-
? "[TTS_SERVICE] Skipping speech with onceKey (already spoken): "; onceKey
|
|
180
|
+
' ? "[TTS_SERVICE] Skipping speech with onceKey (already spoken): "; onceKey
|
|
181
181
|
#end if
|
|
182
182
|
return
|
|
183
183
|
end if
|
|
@@ -197,7 +197,7 @@ namespace Rotor.ViewBuilder
|
|
|
197
197
|
' Check if we should skip duplicate speech
|
|
198
198
|
if dontRepeat and textToSpeak = m.lastSpeech
|
|
199
199
|
#if debug
|
|
200
|
-
? "[TTS_SERVICE] Skipping duplicate speech: "; textToSpeak
|
|
200
|
+
' ? "[TTS_SERVICE] Skipping duplicate speech: "; textToSpeak
|
|
201
201
|
#end if
|
|
202
202
|
return
|
|
203
203
|
end if
|
|
@@ -206,7 +206,7 @@ namespace Rotor.ViewBuilder
|
|
|
206
206
|
' This means: bypass threshold immediately AND set flag to protect from next flush
|
|
207
207
|
if shouldPreventNextFlush
|
|
208
208
|
#if debug
|
|
209
|
-
? "[TTS_SERVICE][OVERRIDE] Current speech bypassing threshold: "; textToSpeak
|
|
209
|
+
' ? "[TTS_SERVICE][OVERRIDE] Current speech bypassing threshold: "; textToSpeak
|
|
210
210
|
#end if
|
|
211
211
|
|
|
212
212
|
' Track if there was pending speech
|
|
@@ -215,7 +215,7 @@ namespace Rotor.ViewBuilder
|
|
|
215
215
|
' IMPORTANT: Execute pending speech FIRST if it exists (maintains correct order)
|
|
216
216
|
if m.pendingSpeech <> invalid
|
|
217
217
|
#if debug
|
|
218
|
-
? "[TTS_SERVICE][OVERRIDE] Executing pending speech first: '"; m.pendingSpeech.textToSpeak; "'"
|
|
218
|
+
' ? "[TTS_SERVICE][OVERRIDE] Executing pending speech first: '"; m.pendingSpeech.textToSpeak; "'"
|
|
219
219
|
#end if
|
|
220
220
|
pendingToExecute = m.pendingSpeech
|
|
221
221
|
m.pendingSpeech = invalid
|
|
@@ -247,7 +247,7 @@ namespace Rotor.ViewBuilder
|
|
|
247
247
|
' This allows rapid menu navigation to filter properly (threshold replaces pending)
|
|
248
248
|
if m.preventNextFlushFlag or m.isPendingProtected
|
|
249
249
|
#if debug
|
|
250
|
-
? "[TTS_SERVICE][OVERRIDE] Flush blocked, speech goes to threshold: "; textToSpeak
|
|
250
|
+
' ? "[TTS_SERVICE][OVERRIDE] Flush blocked, speech goes to threshold: "; textToSpeak
|
|
251
251
|
#end if
|
|
252
252
|
if m.preventNextFlushFlag
|
|
253
253
|
m.preventNextFlushFlag = false ' Clear flag after first use
|
|
@@ -266,11 +266,11 @@ namespace Rotor.ViewBuilder
|
|
|
266
266
|
' If new request comes within 300ms, replace pending (filter rapid changes)
|
|
267
267
|
#if debug
|
|
268
268
|
if m.pendingSpeech <> invalid
|
|
269
|
-
? "[TTS_SERVICE][THRESHOLD] Replacing pending: '"; m.pendingSpeech.textToSpeak; "' with: '"; textToSpeak; "'"
|
|
269
|
+
' ? "[TTS_SERVICE][THRESHOLD] Replacing pending: '"; m.pendingSpeech.textToSpeak; "' with: '"; textToSpeak; "'"
|
|
270
270
|
else
|
|
271
271
|
flushLabel = ""
|
|
272
272
|
if shouldFlush then flushLabel = " (will flush)"
|
|
273
|
-
? "[TTS_SERVICE][THRESHOLD] Pending: '"; textToSpeak; "' ("; m.debounceDelay; "ms)"; flushLabel
|
|
273
|
+
' ? "[TTS_SERVICE][THRESHOLD] Pending: '"; textToSpeak; "' ("; m.debounceDelay; "ms)"; flushLabel
|
|
274
274
|
end if
|
|
275
275
|
#end if
|
|
276
276
|
m.pendingSpeech = {
|
|
@@ -321,7 +321,7 @@ namespace Rotor.ViewBuilder
|
|
|
321
321
|
|
|
322
322
|
' Execute pending speech after threshold passed
|
|
323
323
|
#if debug
|
|
324
|
-
? "[TTS_SERVICE][THRESHOLD] Timer fired, executing: '"; pendingToExecute.textToSpeak; "'"
|
|
324
|
+
' ? "[TTS_SERVICE][THRESHOLD] Timer fired, executing: '"; pendingToExecute.textToSpeak; "'"
|
|
325
325
|
#end if
|
|
326
326
|
|
|
327
327
|
' IMPORTANT: Clear protection flag BEFORE executing pending speech
|
|
@@ -350,7 +350,7 @@ namespace Rotor.ViewBuilder
|
|
|
350
350
|
if isLongText or isMultiSentence
|
|
351
351
|
shouldFlush = true
|
|
352
352
|
#if debug
|
|
353
|
-
? "[TTS_SERVICE] Forcing flush for long/multi-sentence text (len: "; Len(textToSpeak); ", multi: "; isMultiSentence; ")"
|
|
353
|
+
' ? "[TTS_SERVICE] Forcing flush for long/multi-sentence text (len: "; Len(textToSpeak); ", multi: "; isMultiSentence; ")"
|
|
354
354
|
#end if
|
|
355
355
|
end if
|
|
356
356
|
|
|
@@ -372,7 +372,7 @@ namespace Rotor.ViewBuilder
|
|
|
372
372
|
' Handle flush with prevention protection
|
|
373
373
|
if shouldFlush and m.preventNextFlushFlag
|
|
374
374
|
#if debug
|
|
375
|
-
? "[TTS_SERVICE] Flush blocked by preventNextFlush protection"
|
|
375
|
+
' ? "[TTS_SERVICE] Flush blocked by preventNextFlush protection"
|
|
376
376
|
#end if
|
|
377
377
|
shouldFlush = false ' Prevent flush in Say() call too!
|
|
378
378
|
m.preventNextFlushFlag = false
|
|
@@ -391,7 +391,7 @@ namespace Rotor.ViewBuilder
|
|
|
391
391
|
end if
|
|
392
392
|
|
|
393
393
|
#if debug
|
|
394
|
-
? "[TTS_SERVICE] Speaking (ID: "; speechId; "): "; text; " (flush: "; shouldFlush; ", dontRepeat: "; dontRepeat; ")"
|
|
394
|
+
' ? "[TTS_SERVICE] Speaking (ID: "; speechId; "): "; text; " (flush: "; shouldFlush; ", dontRepeat: "; dontRepeat; ")"
|
|
395
395
|
#end if
|
|
396
396
|
|
|
397
397
|
return speechId
|
|
@@ -560,7 +560,7 @@ namespace Rotor.ViewBuilder
|
|
|
560
560
|
end if
|
|
561
561
|
|
|
562
562
|
#if debug
|
|
563
|
-
? "[TTS_SERVICE] Stop speech - flushed all speech and canceled pending"
|
|
563
|
+
' ? "[TTS_SERVICE] Stop speech - flushed all speech and canceled pending"
|
|
564
564
|
#end if
|
|
565
565
|
end sub
|
|
566
566
|
|
|
@@ -585,7 +585,7 @@ namespace Rotor.ViewBuilder
|
|
|
585
585
|
if m.skipCache.DoesExist(key)
|
|
586
586
|
m.skipCache.Delete(key)
|
|
587
587
|
#if debug
|
|
588
|
-
? "[TTS_SERVICE] Removed onceKey from cache: "; key
|
|
588
|
+
' ? "[TTS_SERVICE] Removed onceKey from cache: "; key
|
|
589
589
|
#end if
|
|
590
590
|
end if
|
|
591
591
|
end sub
|
|
@@ -596,7 +596,7 @@ namespace Rotor.ViewBuilder
|
|
|
596
596
|
public sub enable()
|
|
597
597
|
if not m.getIsDeviceAudioGuideEnabled()
|
|
598
598
|
#if debug
|
|
599
|
-
? "[TTS_SERVICE][WARNING] Cannot enable - device AudioGuide is disabled"
|
|
599
|
+
' ? "[TTS_SERVICE][WARNING] Cannot enable - device AudioGuide is disabled"
|
|
600
600
|
#end if
|
|
601
601
|
return
|
|
602
602
|
end if
|
|
@@ -608,7 +608,7 @@ namespace Rotor.ViewBuilder
|
|
|
608
608
|
if rootNode <> invalid and not m.allowNativeAudioGuide
|
|
609
609
|
rootNode.muteAudioGuide = true
|
|
610
610
|
#if debug
|
|
611
|
-
? "[TTS_SERVICE][INFO] Enabled TTS, muted native AudioGuide"
|
|
611
|
+
' ? "[TTS_SERVICE][INFO] Enabled TTS, muted native AudioGuide"
|
|
612
612
|
#end if
|
|
613
613
|
end if
|
|
614
614
|
end sub
|
|
@@ -626,7 +626,7 @@ namespace Rotor.ViewBuilder
|
|
|
626
626
|
if rootNode <> invalid
|
|
627
627
|
rootNode.muteAudioGuide = false
|
|
628
628
|
#if debug
|
|
629
|
-
? "[TTS_SERVICE][INFO] Disabled TTS, unmuted native AudioGuide"
|
|
629
|
+
' ? "[TTS_SERVICE][INFO] Disabled TTS, unmuted native AudioGuide"
|
|
630
630
|
#end if
|
|
631
631
|
end if
|
|
632
632
|
end if
|
|
@@ -638,7 +638,7 @@ namespace Rotor.ViewBuilder
|
|
|
638
638
|
public sub clearOnceCache()
|
|
639
639
|
m.skipCache = {}
|
|
640
640
|
#if debug
|
|
641
|
-
? "[TTS_SERVICE] Once cache cleared"
|
|
641
|
+
' ? "[TTS_SERVICE] Once cache cleared"
|
|
642
642
|
#end if
|
|
643
643
|
end sub
|
|
644
644
|
|
|
@@ -77,9 +77,11 @@ namespace Rotor
|
|
|
77
77
|
' 3. BubblingFocus (ask parent groups)
|
|
78
78
|
'
|
|
79
79
|
' RULE #4: Spatial Navigation Scope
|
|
80
|
-
' -
|
|
81
|
-
' -
|
|
82
|
-
' -
|
|
80
|
+
' - Works within parent group scope
|
|
81
|
+
' - Participants: FocusItems AND direct child Groups with enableSpatialNavigation: true
|
|
82
|
+
' - enableSpatialNavigation default is FALSE (opt-in)
|
|
83
|
+
' - When Group is selected via spatial nav, capturing focus starts into that group
|
|
84
|
+
' - Searches possibleFocusItems + Groups from group.getGroupMembersHIDs()
|
|
83
85
|
'
|
|
84
86
|
' RULE #5: Group Direction Activation
|
|
85
87
|
' Group direction triggers ONLY when:
|
|
@@ -121,13 +123,6 @@ namespace Rotor
|
|
|
121
123
|
' 2. group.defaultFocusId [CONFIGURED]
|
|
122
124
|
' 3. Deep search (if defaultFocusId not found immediately)
|
|
123
125
|
'
|
|
124
|
-
' RULE #11b: Deep Focus Tracking (trackDescendantFocus: true)
|
|
125
|
-
' When a FocusGroup has trackDescendantFocus: true, it stores lastFocusedHID
|
|
126
|
-
' for ANY descendant FocusItem (not just direct children).
|
|
127
|
-
' - Immediate parent ALWAYS stores lastFocusedHID (default behavior)
|
|
128
|
-
' - Ancestor groups with trackDescendantFocus: true ALSO store it
|
|
129
|
-
' - Enables "deep focus memory" - returning to deeply nested items
|
|
130
|
-
'
|
|
131
126
|
' RULE #12: DefaultFocusId Targets
|
|
132
127
|
' - FocusItem node ID β Focus goes directly to it
|
|
133
128
|
' - Group node ID β Capturing continues on that group
|
|
@@ -244,13 +239,17 @@ namespace Rotor
|
|
|
244
239
|
' ---------------------------------------------------------------------
|
|
245
240
|
' beforeDestroy - Hook executed before a widget is destroyed
|
|
246
241
|
'
|
|
247
|
-
' Removes focus config.
|
|
242
|
+
' Removes focus config. Clears global focus if this widget had it.
|
|
248
243
|
'
|
|
249
244
|
' @param {object} scope - The plugin scope (this instance)
|
|
250
245
|
' @param {object} widget - The widget being destroyed
|
|
251
246
|
'
|
|
252
247
|
beforeDestroy: sub(scope as object, widget as object)
|
|
248
|
+
hadFocus = scope.globalFocusHID = widget.HID
|
|
253
249
|
scope.removeFocusConfig(widget.HID)
|
|
250
|
+
if hadFocus
|
|
251
|
+
scope.storeGlobalFocusHID("", "")
|
|
252
|
+
end if
|
|
254
253
|
end sub
|
|
255
254
|
}
|
|
256
255
|
|
|
@@ -391,6 +390,7 @@ namespace Rotor
|
|
|
391
390
|
' State tracking
|
|
392
391
|
globalFocusHID = ""
|
|
393
392
|
globalFocusId = ""
|
|
393
|
+
lastNavigationDirection = ""
|
|
394
394
|
isLongPress = false
|
|
395
395
|
longPressKey = ""
|
|
396
396
|
|
|
@@ -404,6 +404,26 @@ namespace Rotor
|
|
|
404
404
|
distanceCalculator = new Rotor.FocusPluginHelper.ClosestSegmentToPointCalculatorClass()
|
|
405
405
|
longPressTimer = CreateObject("roSGNode", "Timer")
|
|
406
406
|
|
|
407
|
+
' Spatial navigation direction validators (reused across calls)
|
|
408
|
+
spatialValidators = {
|
|
409
|
+
"left": function(segments as object, refSegmentLeft as object, refSegmentRight as object) as object
|
|
410
|
+
right = segments[Rotor.Const.Segment.RIGHT]
|
|
411
|
+
return right.x2 <= refSegmentLeft.x1 ? { isValid: true, segment: right } : { isValid: false }
|
|
412
|
+
end function,
|
|
413
|
+
"up": function(segments as object, refSegmentTop as object, refSegmentBottom as object) as object
|
|
414
|
+
bottom = segments[Rotor.Const.Segment.BOTTOM]
|
|
415
|
+
return bottom.y2 <= refSegmentTop.y1 ? { isValid: true, segment: bottom } : { isValid: false }
|
|
416
|
+
end function,
|
|
417
|
+
"right": function(segments as object, refSegmentLeft as object, refSegmentRight as object) as object
|
|
418
|
+
left = segments[Rotor.Const.Segment.LEFT]
|
|
419
|
+
return left.x1 >= refSegmentRight.x2 ? { isValid: true, segment: left } : { isValid: false }
|
|
420
|
+
end function,
|
|
421
|
+
"down": function(segments as object, refSegmentTop as object, refSegmentBottom as object) as object
|
|
422
|
+
top = segments[Rotor.Const.Segment.TOP]
|
|
423
|
+
return top.y1 >= refSegmentBottom.y2 ? { isValid: true, segment: top } : { isValid: false }
|
|
424
|
+
end function
|
|
425
|
+
}
|
|
426
|
+
|
|
407
427
|
' ---------------------------------------------------------------------
|
|
408
428
|
' init - Initializes the plugin instance
|
|
409
429
|
'
|
|
@@ -511,6 +531,11 @@ namespace Rotor
|
|
|
511
531
|
' Create and register the FocusItem instance
|
|
512
532
|
newFocusItem = new Rotor.FocusPluginHelper.FocusItemClass(config)
|
|
513
533
|
m.focusItemStack.set(HID, newFocusItem)
|
|
534
|
+
|
|
535
|
+
' Restore focus state if this widget had global focus
|
|
536
|
+
if m.globalFocusHID = HID
|
|
537
|
+
newFocusItem.isFocused = true
|
|
538
|
+
end if
|
|
514
539
|
end sub
|
|
515
540
|
|
|
516
541
|
'
|
|
@@ -585,7 +610,8 @@ namespace Rotor
|
|
|
585
610
|
group = m.groupStack.get(ref) ?? m.groupStack.getByNodeId(ref)
|
|
586
611
|
if group <> invalid
|
|
587
612
|
' If group found, find its default/entry focus item recursively.
|
|
588
|
-
|
|
613
|
+
' Use lastNavigationDirection so enableSpatialEnter groups can pick the right entry point
|
|
614
|
+
HID = m.capturingFocus_recursively(group.HID, m.lastNavigationDirection)
|
|
589
615
|
focusItem = m.focusItemStack.get(HID) ' May still be invalid if capture fails
|
|
590
616
|
' else: ref is not a known FocusItem HID or Group identifier
|
|
591
617
|
end if
|
|
@@ -642,16 +668,18 @@ namespace Rotor
|
|
|
642
668
|
if lastFocused <> invalid ' Check if the last focused widget hasn't been destroyed
|
|
643
669
|
' Record the last focused item within its parent group for potential future use (e.g., returning focus)
|
|
644
670
|
lastFocusChainingGroups = m.findAncestorGroups(m.globalFocusHID)
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
if
|
|
649
|
-
|
|
650
|
-
if
|
|
651
|
-
|
|
671
|
+
for i = 0 to lastFocusChainingGroups.Count() - 1
|
|
672
|
+
ancestorGroupHID = lastFocusChainingGroups[i]
|
|
673
|
+
ancestorGroup = m.groupStack.get(ancestorGroupHID)
|
|
674
|
+
if ancestorGroup <> invalid
|
|
675
|
+
' For immediate parent (index 0): set if enableLastFocusId is true (default)
|
|
676
|
+
' For other ancestors: set if enableDeepLastFocusId is enabled
|
|
677
|
+
shouldSetLastFocusId = (i = 0 and ancestorGroup.enableLastFocusId) or (i > 0 and ancestorGroup.enableDeepLastFocusId)
|
|
678
|
+
if shouldSetLastFocusId
|
|
679
|
+
ancestorGroup.setLastFocusedHID(m.globalFocusHID)
|
|
652
680
|
end if
|
|
653
681
|
end if
|
|
654
|
-
end
|
|
682
|
+
end for
|
|
655
683
|
end if
|
|
656
684
|
end if
|
|
657
685
|
|
|
@@ -667,14 +695,6 @@ namespace Rotor
|
|
|
667
695
|
if -1 = Rotor.Utils.findInArray(allAffectedGroups, groupHID)
|
|
668
696
|
allAffectedGroups.unshift(groupHID)
|
|
669
697
|
end if
|
|
670
|
-
|
|
671
|
-
' Deep remember (skip index 0 - immediate parent is handled separately)
|
|
672
|
-
if i > 0 and lastFocused <> invalid
|
|
673
|
-
ancestorGroup = m.groupStack.get(groupHID)
|
|
674
|
-
if ancestorGroup <> invalid and ancestorGroup.trackDescendantFocus = true
|
|
675
|
-
ancestorGroup.setLastFocusedHID(m.globalFocusHID)
|
|
676
|
-
end if
|
|
677
|
-
end if
|
|
678
698
|
end for
|
|
679
699
|
|
|
680
700
|
' Notify all ancestor groups BEFORE applying focus (from highest ancestor to closest parent)
|
|
@@ -743,8 +763,9 @@ namespace Rotor
|
|
|
743
763
|
m.notifyLongPressAtAncestorGroups(isLongPress, key, focused.HID, focusChainGroups)
|
|
744
764
|
end sub
|
|
745
765
|
|
|
746
|
-
function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object) as string
|
|
747
|
-
if focused
|
|
766
|
+
function spatialNavigation(focused as object, direction as string, focusItemsHIDlist as object, bypassFocusedCheck = false as boolean) as string
|
|
767
|
+
' Skip if focused item doesn't participate in spatial nav (unless bypassed for cross-group nav)
|
|
768
|
+
if not bypassFocusedCheck and focused.enableSpatialNavigation = false then return ""
|
|
748
769
|
if direction = Rotor.Const.Direction.BACK then return ""
|
|
749
770
|
|
|
750
771
|
' Remove current focused item from candidates
|
|
@@ -752,9 +773,10 @@ namespace Rotor
|
|
|
752
773
|
if index >= 0 then focusItemsHIDlist.delete(index)
|
|
753
774
|
|
|
754
775
|
' Find closest focusable item in direction
|
|
755
|
-
|
|
776
|
+
focusedMetrics = focused.refreshBounding()
|
|
777
|
+
segments = m.collectSegments(focusedMetrics, direction, focusItemsHIDlist, focused.HID)
|
|
756
778
|
if segments.Count() > 0
|
|
757
|
-
return m.findClosestSegment(segments,
|
|
779
|
+
return m.findClosestSegment(segments, focusedMetrics.middlePoint)
|
|
758
780
|
end if
|
|
759
781
|
|
|
760
782
|
return ""
|
|
@@ -796,9 +818,30 @@ namespace Rotor
|
|
|
796
818
|
if group = invalid then group = m.groupStack.getByNodeId(identifier, ancestorHID)
|
|
797
819
|
if group = invalid then return ""
|
|
798
820
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
821
|
+
newHID = ""
|
|
822
|
+
|
|
823
|
+
' enableSpatialEnter: use spatialNavigation to find closest member
|
|
824
|
+
if group.isSpatialEnterEnabledForDirection(direction)
|
|
825
|
+
if direction <> "" and m.globalFocusHID <> ""
|
|
826
|
+
focused = m.focusItemStack.get(m.globalFocusHID)
|
|
827
|
+
if focused <> invalid
|
|
828
|
+
' Get group members and use spatial navigation to find closest
|
|
829
|
+
members = group.getGroupMembersHIDs()
|
|
830
|
+
if members.count() > 0
|
|
831
|
+
newHID = m.spatialNavigation(focused, direction, members, true)
|
|
832
|
+
' If spatial nav found a group (not a focus item), recursively resolve it
|
|
833
|
+
if newHID <> "" and m.groupStack.has(newHID)
|
|
834
|
+
newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
|
|
835
|
+
end if
|
|
836
|
+
end if
|
|
837
|
+
end if
|
|
838
|
+
end if
|
|
839
|
+
end if
|
|
840
|
+
|
|
841
|
+
' Fallback to getFallbackIdentifier if spatial enter didn't find anything
|
|
842
|
+
if newHID = ""
|
|
843
|
+
newHID = group.getFallbackIdentifier(m.globalFocusHID, direction)
|
|
844
|
+
end if
|
|
802
845
|
|
|
803
846
|
' Check if we found a FocusItem
|
|
804
847
|
if m.focusItemStack.has(newHID)
|
|
@@ -815,7 +858,7 @@ namespace Rotor
|
|
|
815
858
|
|
|
816
859
|
' Prevent capturing by fallback in the same group where original focus was
|
|
817
860
|
' Skip this guard for enableSpatialEnter groups (spatial enter explicitly targets a sibling group's member)
|
|
818
|
-
if not group.
|
|
861
|
+
if not group.isSpatialEnterEnabledForDirection(direction) and newHID <> "" and m.globalFocusHID <> ""
|
|
819
862
|
currentAncestors = m.findAncestorGroups(m.globalFocusHID)
|
|
820
863
|
newAncestors = m.findAncestorGroups(newHID)
|
|
821
864
|
if currentAncestors.Count() > 0 and newAncestors.Count() > 0
|
|
@@ -879,6 +922,9 @@ namespace Rotor
|
|
|
879
922
|
ancestorGroupsCount = ancestorGroups.Count()
|
|
880
923
|
ancestorIndex = 0
|
|
881
924
|
|
|
925
|
+
' Get currently focused item for spatial navigation
|
|
926
|
+
focused = m.focusItemStack.get(m.globalFocusHID)
|
|
927
|
+
|
|
882
928
|
' Bubble up through ancestor groups until we find a target or reach the top
|
|
883
929
|
while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
|
|
884
930
|
' Get next ancestor group
|
|
@@ -902,6 +948,34 @@ namespace Rotor
|
|
|
902
948
|
if otherGroup <> invalid
|
|
903
949
|
newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
|
|
904
950
|
end if
|
|
951
|
+
else
|
|
952
|
+
' No explicit direction - try spatial navigation at this group level
|
|
953
|
+
' This allows navigation between sibling child groups with enableSpatialNavigation
|
|
954
|
+
' Skip during long press to allow bubbling up to parent carousel for continuous scrolling
|
|
955
|
+
' Skip for "back" direction - spatial navigation doesn't apply
|
|
956
|
+
if focused <> invalid and m.isLongPress = false and direction <> Rotor.Const.Direction.BACK
|
|
957
|
+
groupMembers = group.getGroupMembersHIDs()
|
|
958
|
+
' Check if this group has any child groups with spatial nav enabled
|
|
959
|
+
hasSpatialNavGroups = false
|
|
960
|
+
for each memberHID in groupMembers
|
|
961
|
+
if m.groupStack.has(memberHID)
|
|
962
|
+
memberGroup = m.groupStack.get(memberHID)
|
|
963
|
+
if memberGroup.enableSpatialNavigation = true
|
|
964
|
+
hasSpatialNavGroups = true
|
|
965
|
+
exit for
|
|
966
|
+
end if
|
|
967
|
+
end if
|
|
968
|
+
end for
|
|
969
|
+
' If there are spatial-nav-enabled child groups, try spatial navigation
|
|
970
|
+
' Use bypassFocusedCheck=true since we're navigating between groups, not within
|
|
971
|
+
if hasSpatialNavGroups
|
|
972
|
+
newHID = m.spatialNavigation(focused, direction, groupMembers, true)
|
|
973
|
+
' If spatial nav found a group, use capturing focus to enter it
|
|
974
|
+
if newHID <> "" and m.groupStack.has(newHID)
|
|
975
|
+
newHID = m.capturingFocus_recursively(newHID, direction)
|
|
976
|
+
end if
|
|
977
|
+
end if
|
|
978
|
+
end if
|
|
905
979
|
end if
|
|
906
980
|
end if
|
|
907
981
|
|
|
@@ -939,6 +1013,7 @@ namespace Rotor
|
|
|
939
1013
|
|
|
940
1014
|
newHID = ""
|
|
941
1015
|
direction = key
|
|
1016
|
+
m.lastNavigationDirection = direction
|
|
942
1017
|
|
|
943
1018
|
' (1) Pick up current focused item
|
|
944
1019
|
|
|
@@ -1048,56 +1123,44 @@ namespace Rotor
|
|
|
1048
1123
|
end sub
|
|
1049
1124
|
|
|
1050
1125
|
function proceedLongPress() as object
|
|
1126
|
+
if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(m.longPressKey, false, false)
|
|
1051
1127
|
return m.executeNavigationAction(m.longPressKey, true)
|
|
1052
1128
|
end function
|
|
1053
1129
|
|
|
1054
1130
|
' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
validators = {
|
|
1065
|
-
|
|
1066
|
-
"left": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
|
|
1067
|
-
right = segments[Rotor.Const.Segment.RIGHT]
|
|
1068
|
-
' Candidate's right edge must be strictly left of focused element's left edge
|
|
1069
|
-
return right.x2 <= refSegmentLeft.x1 ? { isValid: true, segment: right } : { isValid: false }
|
|
1070
|
-
end function,
|
|
1071
|
-
|
|
1072
|
-
"up": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
|
|
1073
|
-
bottom = segments[Rotor.Const.Segment.BOTTOM]
|
|
1074
|
-
' Candidate's bottom edge must be strictly above focused element's top edge
|
|
1075
|
-
return bottom.y2 <= refSegmentTop.y1 ? { isValid: true, segment: bottom } : { isValid: false }
|
|
1076
|
-
end function,
|
|
1077
|
-
|
|
1078
|
-
"right": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
|
|
1079
|
-
left = segments[Rotor.Const.Segment.LEFT]
|
|
1080
|
-
' Candidate's left edge must be strictly right of focused element's right edge
|
|
1081
|
-
return left.x1 >= refSegmentRight.x2 ? { isValid: true, segment: left } : { isValid: false }
|
|
1082
|
-
end function,
|
|
1083
|
-
|
|
1084
|
-
"down": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
|
|
1085
|
-
top = segments[Rotor.Const.Segment.TOP]
|
|
1086
|
-
' Candidate's top edge must be strictly below focused element's bottom edge
|
|
1087
|
-
return top.y1 >= refSegmentBottom.y2 ? { isValid: true, segment: top } : { isValid: false }
|
|
1088
|
-
end function
|
|
1089
|
-
}
|
|
1131
|
+
' @param focusedMetrics - The metrics object from focused.refreshBounding()
|
|
1132
|
+
' @param focusedHID - The HID of the focused item (to exclude from candidates)
|
|
1133
|
+
function collectSegments(focusedMetrics as object, direction as string, focusItemsHIDlist as object, focusedHID as string) as object
|
|
1134
|
+
refSegments = focusedMetrics.segments
|
|
1135
|
+
refSegmentTop = refSegments[Rotor.Const.Segment.TOP]
|
|
1136
|
+
refSegmentRight = refSegments[Rotor.Const.Segment.RIGHT]
|
|
1137
|
+
refSegmentLeft = refSegments[Rotor.Const.Segment.LEFT]
|
|
1138
|
+
refSegmentBottom = refSegments[Rotor.Const.Segment.BOTTOM]
|
|
1139
|
+
|
|
1090
1140
|
segments = {}
|
|
1091
|
-
validator =
|
|
1141
|
+
validator = m.spatialValidators[direction]
|
|
1092
1142
|
for each HID in focusItemsHIDlist
|
|
1093
|
-
if HID <>
|
|
1094
|
-
|
|
1095
|
-
|
|
1143
|
+
if HID <> focusedHID
|
|
1144
|
+
' Try to get as FocusItem first, then as Group
|
|
1145
|
+
candidate = m.focusItemStack.get(HID)
|
|
1146
|
+
isGroup = false
|
|
1147
|
+
if candidate = invalid
|
|
1148
|
+
candidate = m.groupStack.get(HID)
|
|
1149
|
+
isGroup = true
|
|
1150
|
+
end if
|
|
1151
|
+
if candidate = invalid then continue for
|
|
1152
|
+
|
|
1153
|
+
' Skip disabled items - they should not be candidates for spatial navigation
|
|
1154
|
+
if not isGroup and candidate.isEnabled = false then continue for
|
|
1155
|
+
|
|
1156
|
+
candidateMetrics = candidate.refreshBounding()
|
|
1157
|
+
candidateLeft = candidateMetrics.segments[Rotor.Const.Segment.LEFT]
|
|
1158
|
+
candidateTop = candidateMetrics.segments[Rotor.Const.Segment.TOP]
|
|
1096
1159
|
' Pass appropriate reference segments based on direction
|
|
1097
1160
|
if direction = "left" or direction = "right"
|
|
1098
|
-
result = validator(
|
|
1161
|
+
result = validator(candidateMetrics.segments, refSegmentLeft, refSegmentRight)
|
|
1099
1162
|
else ' up or down
|
|
1100
|
-
result = validator(
|
|
1163
|
+
result = validator(candidateMetrics.segments, refSegmentTop, refSegmentBottom)
|
|
1101
1164
|
end if
|
|
1102
1165
|
if result.isValid
|
|
1103
1166
|
segments[HID] = result.segment
|
|
@@ -1210,6 +1273,7 @@ namespace Rotor
|
|
|
1210
1273
|
m.autoSetIsFocusedState = config.autoSetIsFocusedState ?? true
|
|
1211
1274
|
|
|
1212
1275
|
m.isEnabled = config.isEnabled ?? true
|
|
1276
|
+
m.enableSpatialNavigation = config.enableSpatialNavigation ?? false
|
|
1213
1277
|
m.staticDirection = {}
|
|
1214
1278
|
m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
|
|
1215
1279
|
m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
|
|
@@ -1237,6 +1301,7 @@ namespace Rotor
|
|
|
1237
1301
|
idByKeys as object
|
|
1238
1302
|
isEnabled as boolean
|
|
1239
1303
|
isFocused as boolean
|
|
1304
|
+
enableSpatialNavigation as boolean
|
|
1240
1305
|
onFocusChanged as dynamic
|
|
1241
1306
|
onFocus as dynamic
|
|
1242
1307
|
onBlur as dynamic
|
|
@@ -1244,6 +1309,47 @@ namespace Rotor
|
|
|
1244
1309
|
node as object
|
|
1245
1310
|
widget as object
|
|
1246
1311
|
|
|
1312
|
+
protected metrics = {
|
|
1313
|
+
segments: {}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function refreshBounding() as object
|
|
1317
|
+
b = m.node.sceneBoundingRect()
|
|
1318
|
+
rotation = m.node.rotation
|
|
1319
|
+
|
|
1320
|
+
if rotation = 0
|
|
1321
|
+
if b.y = 0 and b.x = 0
|
|
1322
|
+
t = m.node.translation
|
|
1323
|
+
b.x += t[0]
|
|
1324
|
+
b.y += t[1]
|
|
1325
|
+
end if
|
|
1326
|
+
|
|
1327
|
+
m.metrics.append(b)
|
|
1328
|
+
m.metrics.segments[Rotor.Const.Segment.LEFT] = {
|
|
1329
|
+
x1: b.x, y1: b.y,
|
|
1330
|
+
x2: b.x, y2: b.y + b.height
|
|
1331
|
+
}
|
|
1332
|
+
m.metrics.segments[Rotor.Const.Segment.TOP] = {
|
|
1333
|
+
x1: b.x, y1: b.y,
|
|
1334
|
+
x2: b.x + b.width, y2: b.y
|
|
1335
|
+
}
|
|
1336
|
+
m.metrics.segments[Rotor.Const.Segment.RIGHT] = {
|
|
1337
|
+
x1: b.x + b.width, y1: b.y,
|
|
1338
|
+
x2: b.x + b.width, y2: b.y + b.height
|
|
1339
|
+
}
|
|
1340
|
+
m.metrics.segments[Rotor.Const.Segment.BOTTOM] = {
|
|
1341
|
+
x1: b.x, y1: b.y + b.height,
|
|
1342
|
+
x2: b.x + b.width, y2: b.y + b.height
|
|
1343
|
+
}
|
|
1344
|
+
m.metrics.middlePoint = {
|
|
1345
|
+
x: b.x + b.width / 2,
|
|
1346
|
+
y: b.y + b.height / 2
|
|
1347
|
+
}
|
|
1348
|
+
end if
|
|
1349
|
+
|
|
1350
|
+
return m.metrics
|
|
1351
|
+
end function
|
|
1352
|
+
|
|
1247
1353
|
function getStaticNodeIdInDirection(direction as dynamic) as dynamic
|
|
1248
1354
|
direction = m.staticDirection[direction]
|
|
1249
1355
|
if Rotor.Utils.isFunction(direction)
|
|
@@ -1293,13 +1399,30 @@ namespace Rotor
|
|
|
1293
1399
|
m.defaultFocusId = config.defaultFocusId ?? ""
|
|
1294
1400
|
m.lastFocusedHID = config.lastFocusedHID ?? ""
|
|
1295
1401
|
m.enableSpatialEnter = config.enableSpatialEnter ?? false
|
|
1296
|
-
m.
|
|
1402
|
+
m.enableLastFocusId = config.enableLastFocusId ?? true
|
|
1403
|
+
m.enableDeepLastFocusId = config.enableDeepLastFocusId ?? false
|
|
1297
1404
|
end sub
|
|
1298
1405
|
|
|
1299
1406
|
defaultFocusId as string
|
|
1300
1407
|
lastFocusedHID as string
|
|
1301
|
-
enableSpatialEnter as boolean
|
|
1302
|
-
|
|
1408
|
+
enableSpatialEnter as dynamic ' boolean | { up?: boolean, down?: boolean, left?: boolean, right?: boolean }
|
|
1409
|
+
enableLastFocusId as boolean
|
|
1410
|
+
enableDeepLastFocusId as boolean
|
|
1411
|
+
|
|
1412
|
+
'
|
|
1413
|
+
' isSpatialEnterEnabledForDirection - Checks if spatial enter is enabled for a specific direction
|
|
1414
|
+
'
|
|
1415
|
+
' @param {string} direction - The direction to check (up, down, left, right)
|
|
1416
|
+
' @returns {boolean} True if spatial enter is enabled for the direction
|
|
1417
|
+
'
|
|
1418
|
+
function isSpatialEnterEnabledForDirection(direction as string) as boolean
|
|
1419
|
+
if Rotor.Utils.isBoolean(m.enableSpatialEnter)
|
|
1420
|
+
return m.enableSpatialEnter
|
|
1421
|
+
else if Rotor.Utils.isAssociativeArray(m.enableSpatialEnter)
|
|
1422
|
+
return m.enableSpatialEnter[direction] = true
|
|
1423
|
+
end if
|
|
1424
|
+
return false
|
|
1425
|
+
end function
|
|
1303
1426
|
focusItemsRef as object
|
|
1304
1427
|
groupsRef as object
|
|
1305
1428
|
isFocusItem = false
|
|
@@ -1312,6 +1435,7 @@ namespace Rotor
|
|
|
1312
1435
|
function getGroupMembersHIDs()
|
|
1313
1436
|
' Collect all focusItems that are descendants of this group
|
|
1314
1437
|
' Exclude items that belong to nested sub-groups
|
|
1438
|
+
' Also include direct child groups with enableSpatialNavigation: true
|
|
1315
1439
|
focusItems = m.focusItemsRef.getAll()
|
|
1316
1440
|
groups = m.groupsRef.getAll()
|
|
1317
1441
|
HIDlen = Len(m.HID)
|
|
@@ -1319,6 +1443,7 @@ namespace Rotor
|
|
|
1319
1443
|
groupsKeys = groups.keys()
|
|
1320
1444
|
groupsCount = groups.Count()
|
|
1321
1445
|
|
|
1446
|
+
' Collect focusItems (existing logic)
|
|
1322
1447
|
for each focusItemHID in focusItems
|
|
1323
1448
|
' Check if focusItem is a descendant of this group
|
|
1324
1449
|
isDescendant = Left(focusItemHID, HIDlen) = m.HID
|
|
@@ -1338,6 +1463,36 @@ namespace Rotor
|
|
|
1338
1463
|
end if
|
|
1339
1464
|
end for
|
|
1340
1465
|
|
|
1466
|
+
' Collect direct child groups with enableSpatialNavigation: true
|
|
1467
|
+
for i = 0 to groupsCount - 1
|
|
1468
|
+
childGroupHID = groupsKeys[i]
|
|
1469
|
+
childGroupHIDlen = Len(childGroupHID)
|
|
1470
|
+
|
|
1471
|
+
' Check if it's a descendant of this group (but not this group itself)
|
|
1472
|
+
if childGroupHIDlen > HIDlen and Left(childGroupHID, HIDlen) = m.HID
|
|
1473
|
+
childGroup = groups[childGroupHID]
|
|
1474
|
+
|
|
1475
|
+
' Only include if enableSpatialNavigation is true
|
|
1476
|
+
if childGroup.enableSpatialNavigation = true
|
|
1477
|
+
' Check if it's a DIRECT child (no intermediate groups)
|
|
1478
|
+
isDirectChild = true
|
|
1479
|
+
for j = 0 to groupsCount - 1
|
|
1480
|
+
intermediateHID = groupsKeys[j]
|
|
1481
|
+
intermediateLen = Len(intermediateHID)
|
|
1482
|
+
' Check if there's a group between this group and the child
|
|
1483
|
+
if intermediateLen > HIDlen and intermediateLen < childGroupHIDlen
|
|
1484
|
+
if Left(childGroupHID, intermediateLen) = intermediateHID
|
|
1485
|
+
isDirectChild = false
|
|
1486
|
+
exit for
|
|
1487
|
+
end if
|
|
1488
|
+
end if
|
|
1489
|
+
end for
|
|
1490
|
+
|
|
1491
|
+
if isDirectChild then collection.push(childGroupHID)
|
|
1492
|
+
end if
|
|
1493
|
+
end if
|
|
1494
|
+
end for
|
|
1495
|
+
|
|
1341
1496
|
return collection
|
|
1342
1497
|
end function
|
|
1343
1498
|
|
|
@@ -1362,41 +1517,15 @@ namespace Rotor
|
|
|
1362
1517
|
end if
|
|
1363
1518
|
end function
|
|
1364
1519
|
|
|
1365
|
-
function getFallbackIdentifier(globalFocusHID = "" as string) as string
|
|
1520
|
+
function getFallbackIdentifier(globalFocusHID = "" as string, direction = "" as string) as string
|
|
1366
1521
|
HID = ""
|
|
1367
|
-
' enableSpatialEnter
|
|
1368
|
-
'
|
|
1369
|
-
if not m.enableSpatialEnter and m.lastFocusedHID <> ""
|
|
1370
|
-
return m.lastFocusedHID
|
|
1371
|
-
end if
|
|
1522
|
+
' enableSpatialEnter is handled by capturingFocus_recursively using spatialNavigation
|
|
1523
|
+
' Here we only handle lastFocusedHID and defaultFocusId fallbacks
|
|
1372
1524
|
|
|
1373
|
-
'
|
|
1374
|
-
if m.
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
prevFocused.refreshBounding()
|
|
1378
|
-
refPoint = prevFocused.metrics.middlePoint
|
|
1379
|
-
members = m.getGroupMembersHIDs()
|
|
1380
|
-
minDist = 2147483647
|
|
1381
|
-
closestHID = ""
|
|
1382
|
-
for each memberHID in members
|
|
1383
|
-
if memberHID <> globalFocusHID
|
|
1384
|
-
member = m.focusItemsRef.get(memberHID)
|
|
1385
|
-
if member <> invalid
|
|
1386
|
-
member.refreshBounding()
|
|
1387
|
-
dx = member.metrics.middlePoint.x - refPoint.x
|
|
1388
|
-
dy = member.metrics.middlePoint.y - refPoint.y
|
|
1389
|
-
dist = dx * dx + dy * dy
|
|
1390
|
-
if dist < minDist
|
|
1391
|
-
minDist = dist
|
|
1392
|
-
closestHID = memberHID
|
|
1393
|
-
end if
|
|
1394
|
-
end if
|
|
1395
|
-
end if
|
|
1396
|
-
end for
|
|
1397
|
-
if closestHID <> ""
|
|
1398
|
-
return closestHID
|
|
1399
|
-
end if
|
|
1525
|
+
' Use lastFocusedHID if available AND still exists (check both focusItems and groups)
|
|
1526
|
+
if not m.isSpatialEnterEnabledForDirection(direction) and m.lastFocusedHID <> ""
|
|
1527
|
+
if m.focusItemsRef.has(m.lastFocusedHID) or m.groupsRef.has(m.lastFocusedHID)
|
|
1528
|
+
return m.lastFocusedHID
|
|
1400
1529
|
end if
|
|
1401
1530
|
end if
|
|
1402
1531
|
|
|
@@ -1467,7 +1596,6 @@ namespace Rotor
|
|
|
1467
1596
|
super(config)
|
|
1468
1597
|
|
|
1469
1598
|
m.onSelect = config.onSelect ?? ""
|
|
1470
|
-
m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
|
|
1471
1599
|
m.enableNativeFocus = config.enableNativeFocus ?? false
|
|
1472
1600
|
end sub
|
|
1473
1601
|
|
|
@@ -1479,16 +1607,12 @@ namespace Rotor
|
|
|
1479
1607
|
isFocusItem = true
|
|
1480
1608
|
isGroup = false
|
|
1481
1609
|
enableNativeFocus as boolean
|
|
1482
|
-
enableSpatialNavigation as boolean
|
|
1483
1610
|
onSelect as dynamic
|
|
1484
1611
|
|
|
1485
|
-
private metrics = {
|
|
1486
|
-
segments: {}
|
|
1487
|
-
}
|
|
1488
1612
|
private bounding as object
|
|
1489
1613
|
|
|
1490
1614
|
|
|
1491
|
-
|
|
1615
|
+
override function refreshBounding() as object
|
|
1492
1616
|
b = m.node.sceneBoundingRect()
|
|
1493
1617
|
rotation = m.node.rotation
|
|
1494
1618
|
|
|
@@ -1555,7 +1679,9 @@ namespace Rotor
|
|
|
1555
1679
|
m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
|
|
1556
1680
|
|
|
1557
1681
|
end if
|
|
1558
|
-
|
|
1682
|
+
|
|
1683
|
+
return m.metrics
|
|
1684
|
+
end function
|
|
1559
1685
|
|
|
1560
1686
|
override sub destroy()
|
|
1561
1687
|
m.onSelect = invalid
|