rotor-framework 0.7.5 β†’ 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.5-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.5",
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.5
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.5"
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.5
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.5"
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,18 +818,34 @@ 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
- newHID = group.getFallbackIdentifier()
821
+ newHID = ""
801
822
 
802
- ' Check if we found a FocusItem
803
- if m.focusItemStack.has(newHID)
804
- ' Apply spatial enter feature if enabled
805
- if group.enableSpatialEnter = true and direction <> ""
823
+ ' enableSpatialEnter: use spatialNavigation to find closest member
824
+ if group.isSpatialEnterEnabledForDirection(direction)
825
+ if direction <> "" and m.globalFocusHID <> ""
806
826
  focused = m.focusItemStack.get(m.globalFocusHID)
807
- newSpatialHID = m.spatialNavigation(focused, direction, group.getGroupMembersHIDs())
808
- if newSpatialHID <> "" then newHID = newSpatialHID
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
809
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
810
845
 
846
+ ' Check if we found a FocusItem
847
+ if m.focusItemStack.has(newHID)
848
+ ' noop β€” direct focusItem resolved
811
849
  else if newHID <> ""
812
850
  ' Try to find as group first, then deep search
813
851
  newHID = m.capturingFocus_recursively(newHID, direction, group.HID)
@@ -819,7 +857,8 @@ namespace Rotor
819
857
  end if
820
858
 
821
859
  ' Prevent capturing by fallback in the same group where original focus was
822
- if newHID <> "" and m.globalFocusHID <> ""
860
+ ' Skip this guard for enableSpatialEnter groups (spatial enter explicitly targets a sibling group's member)
861
+ if not group.isSpatialEnterEnabledForDirection(direction) and newHID <> "" and m.globalFocusHID <> ""
823
862
  currentAncestors = m.findAncestorGroups(m.globalFocusHID)
824
863
  newAncestors = m.findAncestorGroups(newHID)
825
864
  if currentAncestors.Count() > 0 and newAncestors.Count() > 0
@@ -883,6 +922,9 @@ namespace Rotor
883
922
  ancestorGroupsCount = ancestorGroups.Count()
884
923
  ancestorIndex = 0
885
924
 
925
+ ' Get currently focused item for spatial navigation
926
+ focused = m.focusItemStack.get(m.globalFocusHID)
927
+
886
928
  ' Bubble up through ancestor groups until we find a target or reach the top
887
929
  while Rotor.Utils.isString(newHID) and newHID = "" and ancestorIndex < ancestorGroupsCount
888
930
  ' Get next ancestor group
@@ -906,6 +948,34 @@ namespace Rotor
906
948
  if otherGroup <> invalid
907
949
  newHID = m.capturingFocus_recursively(otherGroup.HID, direction)
908
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
909
979
  end if
910
980
  end if
911
981
 
@@ -923,7 +993,6 @@ namespace Rotor
923
993
  end if
924
994
  ' Prevent any navigation if it is disabled
925
995
  #if debug
926
- if m.enableFocusNavigation = false and press = true then print "[PLUGIN][FOCUS][INFO] Focus navigation is disabled. Call enableFocusNavigation(true) to make it enabled"
927
996
  #end if
928
997
  if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(key, false, false)
929
998
  ' Execute action according to key press
@@ -944,6 +1013,7 @@ namespace Rotor
944
1013
 
945
1014
  newHID = ""
946
1015
  direction = key
1016
+ m.lastNavigationDirection = direction
947
1017
 
948
1018
  ' (1) Pick up current focused item
949
1019
 
@@ -1053,56 +1123,44 @@ namespace Rotor
1053
1123
  end sub
1054
1124
 
1055
1125
  function proceedLongPress() as object
1126
+ if m.enableFocusNavigation = false then return m.parseOnKeyEventResult(m.longPressKey, false, false)
1056
1127
  return m.executeNavigationAction(m.longPressKey, true)
1057
1128
  end function
1058
1129
 
1059
1130
  ' Find all the relevant(closest in direction) segments that are in the same group as the focused item.
1060
- function collectSegments(focused as object, direction as string, focusItemsHIDlist as object) as object
1061
- focused.refreshBounding()
1062
-
1063
- refSegmentTop = focused.metrics.segments[Rotor.Const.Segment.TOP]
1064
- refSegmentRight = focused.metrics.segments[Rotor.Const.Segment.RIGHT]
1065
- refSegmentLeft = focused.metrics.segments[Rotor.Const.Segment.LEFT]
1066
- refSegmentBottom = focused.metrics.segments[Rotor.Const.Segment.BOTTOM]
1067
- referencePoint = { x: (refSegmentTop.x1 + refSegmentRight.x2) / 2, y: (refSegmentTop.y1 + refSegmentRight.y2) / 2 }
1068
-
1069
- validators = {
1070
-
1071
- "left": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
1072
- right = segments[Rotor.Const.Segment.RIGHT]
1073
- ' Candidate's right edge must be strictly left of focused element's left edge
1074
- return right.x2 <= refSegmentLeft.x1 ? { isValid: true, segment: right } : { isValid: false }
1075
- end function,
1076
-
1077
- "up": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
1078
- bottom = segments[Rotor.Const.Segment.BOTTOM]
1079
- ' Candidate's bottom edge must be strictly above focused element's top edge
1080
- return bottom.y2 <= refSegmentTop.y1 ? { isValid: true, segment: bottom } : { isValid: false }
1081
- end function,
1082
-
1083
- "right": function(referencePoint as object, segments as object, refSegmentLeft as object, refSegmentRight as object) as object
1084
- left = segments[Rotor.Const.Segment.LEFT]
1085
- ' Candidate's left edge must be strictly right of focused element's right edge
1086
- return left.x1 >= refSegmentRight.x2 ? { isValid: true, segment: left } : { isValid: false }
1087
- end function,
1088
-
1089
- "down": function(referencePoint as object, segments as object, refSegmentTop as object, refSegmentBottom as object) as object
1090
- top = segments[Rotor.Const.Segment.TOP]
1091
- ' Candidate's top edge must be strictly below focused element's bottom edge
1092
- return top.y1 >= refSegmentBottom.y2 ? { isValid: true, segment: top } : { isValid: false }
1093
- end function
1094
- }
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
+
1095
1140
  segments = {}
1096
- validator = validators[direction]
1141
+ validator = m.spatialValidators[direction]
1097
1142
  for each HID in focusItemsHIDlist
1098
- if HID <> focused.HID
1099
- focusItem = m.focusItemStack.get(HID)
1100
- 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]
1101
1159
  ' Pass appropriate reference segments based on direction
1102
1160
  if direction = "left" or direction = "right"
1103
- result = validator(referencePoint, focusItem.metrics.segments, refSegmentLeft, refSegmentRight)
1161
+ result = validator(candidateMetrics.segments, refSegmentLeft, refSegmentRight)
1104
1162
  else ' up or down
1105
- result = validator(referencePoint, focusItem.metrics.segments, refSegmentTop, refSegmentBottom)
1163
+ result = validator(candidateMetrics.segments, refSegmentTop, refSegmentBottom)
1106
1164
  end if
1107
1165
  if result.isValid
1108
1166
  segments[HID] = result.segment
@@ -1215,6 +1273,7 @@ namespace Rotor
1215
1273
  m.autoSetIsFocusedState = config.autoSetIsFocusedState ?? true
1216
1274
 
1217
1275
  m.isEnabled = config.isEnabled ?? true
1276
+ m.enableSpatialNavigation = config.enableSpatialNavigation ?? false
1218
1277
  m.staticDirection = {}
1219
1278
  m.staticDirection[Rotor.Const.Direction.UP] = config.up ?? ""
1220
1279
  m.staticDirection[Rotor.Const.Direction.RIGHT] = config.right ?? ""
@@ -1242,6 +1301,7 @@ namespace Rotor
1242
1301
  idByKeys as object
1243
1302
  isEnabled as boolean
1244
1303
  isFocused as boolean
1304
+ enableSpatialNavigation as boolean
1245
1305
  onFocusChanged as dynamic
1246
1306
  onFocus as dynamic
1247
1307
  onBlur as dynamic
@@ -1249,6 +1309,47 @@ namespace Rotor
1249
1309
  node as object
1250
1310
  widget as object
1251
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
+
1252
1353
  function getStaticNodeIdInDirection(direction as dynamic) as dynamic
1253
1354
  direction = m.staticDirection[direction]
1254
1355
  if Rotor.Utils.isFunction(direction)
@@ -1298,16 +1399,32 @@ namespace Rotor
1298
1399
  m.defaultFocusId = config.defaultFocusId ?? ""
1299
1400
  m.lastFocusedHID = config.lastFocusedHID ?? ""
1300
1401
  m.enableSpatialEnter = config.enableSpatialEnter ?? false
1301
- m.trackDescendantFocus = config.trackDescendantFocus ?? false
1402
+ m.enableLastFocusId = config.enableLastFocusId ?? true
1403
+ m.enableDeepLastFocusId = config.enableDeepLastFocusId ?? false
1302
1404
  end sub
1303
1405
 
1304
1406
  defaultFocusId as string
1305
1407
  lastFocusedHID as string
1306
- enableSpatialEnter as boolean
1307
- 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
1308
1426
  focusItemsRef as object
1309
1427
  groupsRef as object
1310
-
1311
1428
  isFocusItem = false
1312
1429
  isGroup = true
1313
1430
 
@@ -1318,6 +1435,7 @@ namespace Rotor
1318
1435
  function getGroupMembersHIDs()
1319
1436
  ' Collect all focusItems that are descendants of this group
1320
1437
  ' Exclude items that belong to nested sub-groups
1438
+ ' Also include direct child groups with enableSpatialNavigation: true
1321
1439
  focusItems = m.focusItemsRef.getAll()
1322
1440
  groups = m.groupsRef.getAll()
1323
1441
  HIDlen = Len(m.HID)
@@ -1325,6 +1443,7 @@ namespace Rotor
1325
1443
  groupsKeys = groups.keys()
1326
1444
  groupsCount = groups.Count()
1327
1445
 
1446
+ ' Collect focusItems (existing logic)
1328
1447
  for each focusItemHID in focusItems
1329
1448
  ' Check if focusItem is a descendant of this group
1330
1449
  isDescendant = Left(focusItemHID, HIDlen) = m.HID
@@ -1344,6 +1463,36 @@ namespace Rotor
1344
1463
  end if
1345
1464
  end for
1346
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
+
1347
1496
  return collection
1348
1497
  end function
1349
1498
 
@@ -1368,34 +1517,40 @@ namespace Rotor
1368
1517
  end if
1369
1518
  end function
1370
1519
 
1371
- function getFallbackIdentifier() as string
1520
+ function getFallbackIdentifier(globalFocusHID = "" as string, direction = "" as string) as string
1372
1521
  HID = ""
1373
- if m.lastFocusedHID <> ""
1374
- return m.lastFocusedHID
1375
- else
1376
- if Rotor.Utils.isFunction(m.defaultFocusId)
1377
- defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
1378
- else
1379
- defaultFocusId = m.defaultFocusId
1522
+ ' enableSpatialEnter is handled by capturingFocus_recursively using spatialNavigation
1523
+ ' Here we only handle lastFocusedHID and defaultFocusId fallbacks
1524
+
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
1380
1529
  end if
1530
+ end if
1381
1531
 
1382
- if defaultFocusId <> ""
1383
- focusItemsHIDlist = m.getGroupMembersHIDs()
1384
- if focusItemsHIDlist.Count() > 0
1532
+ ' Default: use defaultFocusId expression
1533
+ if Rotor.Utils.isFunction(m.defaultFocusId)
1534
+ defaultFocusId = Rotor.Utils.callbackScoped(m.defaultFocusId, m.widget) ?? ""
1535
+ else
1536
+ defaultFocusId = m.defaultFocusId
1537
+ end if
1385
1538
 
1386
- ' Try find valid HID in focusItems by node id
1387
- focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1388
- if focusItemHID <> ""
1389
- HID = focusItemHID
1390
- end if
1539
+ if defaultFocusId <> ""
1540
+ focusItemsHIDlist = m.getGroupMembersHIDs()
1541
+ if focusItemsHIDlist.Count() > 0
1391
1542
 
1392
- else
1543
+ ' Try find valid HID in focusItems by node id
1544
+ focusItemHID = m.findHIDinFocusItemsByNodeId(defaultFocusId, focusItemsHIDlist)
1545
+ if focusItemHID <> ""
1546
+ HID = focusItemHID
1547
+ end if
1548
+
1549
+ else
1393
1550
 
1394
- return defaultFocusId
1551
+ return defaultFocusId
1395
1552
 
1396
- end if
1397
1553
  end if
1398
-
1399
1554
  end if
1400
1555
 
1401
1556
  return HID
@@ -1441,7 +1596,6 @@ namespace Rotor
1441
1596
  super(config)
1442
1597
 
1443
1598
  m.onSelect = config.onSelect ?? ""
1444
- m.enableSpatialNavigation = config.enableSpatialNavigation ?? true
1445
1599
  m.enableNativeFocus = config.enableNativeFocus ?? false
1446
1600
  end sub
1447
1601
 
@@ -1453,16 +1607,12 @@ namespace Rotor
1453
1607
  isFocusItem = true
1454
1608
  isGroup = false
1455
1609
  enableNativeFocus as boolean
1456
- enableSpatialNavigation as boolean
1457
1610
  onSelect as dynamic
1458
1611
 
1459
- private metrics = {
1460
- segments: {}
1461
- }
1462
1612
  private bounding as object
1463
1613
 
1464
1614
 
1465
- sub refreshBounding()
1615
+ override function refreshBounding() as object
1466
1616
  b = m.node.sceneBoundingRect()
1467
1617
  rotation = m.node.rotation
1468
1618
 
@@ -1529,7 +1679,9 @@ namespace Rotor
1529
1679
  m.metrics.middlePoint = { x: rotatedMiddlePoint.x1, y: rotatedMiddlePoint.y1 }
1530
1680
 
1531
1681
  end if
1532
- end sub
1682
+
1683
+ return m.metrics
1684
+ end function
1533
1685
 
1534
1686
  override sub destroy()
1535
1687
  m.onSelect = invalid