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 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
- ![Version](https://img.shields.io/badge/version-v0.7.6-blue?label=Documents%20TAG)
99
+ ![Version](https://img.shields.io/badge/version-v0.7.7-blue?label=Documents%20TAG)
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.6",
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.6
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.6"
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.6
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.6"
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 ' Identifies this as a ViewModel
24
+ isViewModel = true ' Identifies this as a ViewModel
25
25
 
26
- viewModelState = {} ' Mutable state shared across ViewModel's widgets
27
- props = {} ' Immutable props shared across ViewModel's widgets
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
- ' Get i18n service
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
- for each payload in Rotor.Utils.ensureArray(payloads)
102
- if payload.DoesExist("id") = false
103
- ' Self update
104
- payload.id = m.id
105
- payload.HID = m.HID
106
- else if payload.id <> m.id
107
- ' Update descendants starting from this widget
108
- payload.parentHID = m.HID
109
- else
110
- ' Update descendants starting from parent widget
111
- payload.parentHID = m.parentHID
112
- end if
113
- end for
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
- ' - ONLY works within a single group
81
- ' - Cannot cross into sibling or parent groups
82
- ' - Searches only possibleFocusItems from group.getGroupMembersHIDs()
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
- HID = m.capturingFocus_recursively(group.HID)
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
- if lastFocusChainingGroups.Count() > 0
646
- ' Always set on immediate parent (index 0)
647
- parentGroupHID = lastFocusChainingGroups[0]
648
- if parentGroupHID <> invalid and parentGroupHID <> ""
649
- group = m.groupStack.get(parentGroupHID)
650
- if group <> invalid
651
- group.setLastFocusedHID(m.globalFocusHID)
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 if
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.enableSpatialNavigation = false then return ""
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
- segments = m.collectSegments(focused, direction, focusItemsHIDlist)
776
+ focusedMetrics = focused.refreshBounding()
777
+ segments = m.collectSegments(focusedMetrics, direction, focusItemsHIDlist, focused.HID)
756
778
  if segments.Count() > 0
757
- return m.findClosestSegment(segments, focused.metrics.middlePoint)
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
- ' Get fallback identifier for this group
800
- ' (enableSpatialEnter groups return the spatially closest member here)
801
- newHID = group.getFallbackIdentifier(m.globalFocusHID)
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.enableSpatialEnter and newHID <> "" and m.globalFocusHID <> ""
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
- function collectSegments(focused as object, direction as string, focusItemsHIDlist as object) as object
1056
- focused.refreshBounding()
1057
-
1058
- refSegmentTop = focused.metrics.segments[Rotor.Const.Segment.TOP]
1059
- refSegmentRight = focused.metrics.segments[Rotor.Const.Segment.RIGHT]
1060
- refSegmentLeft = focused.metrics.segments[Rotor.Const.Segment.LEFT]
1061
- refSegmentBottom = focused.metrics.segments[Rotor.Const.Segment.BOTTOM]
1062
- referencePoint = { x: (refSegmentTop.x1 + refSegmentRight.x2) / 2, y: (refSegmentTop.y1 + refSegmentRight.y2) / 2 }
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 = validators[direction]
1141
+ validator = m.spatialValidators[direction]
1092
1142
  for each HID in focusItemsHIDlist
1093
- if HID <> focused.HID
1094
- focusItem = m.focusItemStack.get(HID)
1095
- focusItem.refreshBounding()
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(referencePoint, focusItem.metrics.segments, refSegmentLeft, refSegmentRight)
1161
+ result = validator(candidateMetrics.segments, refSegmentLeft, refSegmentRight)
1099
1162
  else ' up or down
1100
- result = validator(referencePoint, focusItem.metrics.segments, refSegmentTop, refSegmentBottom)
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.trackDescendantFocus = config.trackDescendantFocus ?? false
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
- trackDescendantFocus as boolean
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 takes priority over lastFocusedHID
1368
- ' (lastFocusedHID may be stale from slot recycling)
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
- ' enableSpatialEnter: return the spatially closest member to the current focus
1374
- if m.enableSpatialEnter = true and globalFocusHID <> ""
1375
- prevFocused = m.focusItemsRef.get(globalFocusHID)
1376
- if prevFocused <> invalid
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
- sub refreshBounding()
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
- end sub
1682
+
1683
+ return m.metrics
1684
+ end function
1559
1685
 
1560
1686
  override sub destroy()
1561
1687
  m.onSelect = invalid