@@ -63,25 +63,28 @@ export function Menu({ width, height, zoom, tree, onClose, sinkGroup }: MenuProp
6363 const [ openItems , setOpenItems ] = useState < Set < string > > ( ( ) => new Set ( ) ) ;
6464 // Visual highlight state. Updated by LV_EVENT_FOCUSED via onItemFocus so
6565 // the blue text-color tracks whichever widget LVGL actually has focused.
66- const [ focusedIdx , setFocusedIdx ] = useState ( 0 ) ;
66+ // Tracked by item id (not flat-array index) so non-focusable separator
67+ // rows can sit in the flat list without breaking the index alignment
68+ // between rendered rows and LVGL refs.
69+ const [ focusedItemId , setFocusedItemId ] = useState < string > ( "" ) ;
6770 // Authoritative navigation cursor. ONLY moved by explicit user actions
6871 // (arrow nav, click) and by the rebuild useEffect. Specifically NOT
6972 // touched by onItemFocus, because expanding/collapsing a submenu causes
7073 // LVGL to fire stray LV_EVENT_FOCUSED events during the React mutation
7174 // phase: every newly-created Text widget auto-joins lv_group_get_default
7275 // (see deps/.../components/text/text.cpp:9), which juggles the default
7376 // group's focused obj. If we let those events drive the ref, the next
74- // useEffect read would clamp to whatever junk widget LVGL landed on (in
75- // practice always 0), making submenu-expand jump focus to the top item .
76- const focusedIdxRef = useRef ( 0 ) ;
77+ // useEffect read would clamp to whatever junk widget LVGL landed on,
78+ // making submenu-expand jump focus.
79+ const focusedItemIdRef = useRef < string > ( "" ) ;
7780 // Suppresses onItemFocus side-effects while a submenu toggle is in
7881 // flight. Set true by `activate` before setOpenItems, cleared by the
7982 // group-rebuild useEffect once the new group is wired up. Starts true so
8083 // initial mount's creation-time focus events are also ignored.
8184 const isRebuildingRef = useRef ( true ) ;
82- const onItemFocus = useCallback ( ( idx : number ) => {
85+ const onItemFocus = useCallback ( ( id : string ) => {
8386 if ( isRebuildingRef . current ) return ;
84- setFocusedIdx ( idx ) ;
87+ setFocusedItemId ( id ) ;
8588 } , [ ] ) ;
8689
8790 // Inner scrollable View ref + cached item height. The scroll-follow effect
@@ -149,19 +152,24 @@ export function Menu({ width, height, zoom, tree, onClose, sinkGroup }: MenuProp
149152 groupRef . current = group ;
150153 const orderedRefs = getOrderedRefs ( ) ;
151154 for ( const ref of orderedRefs ) group . add ( ref ) ;
152- // Clamp the existing focus index into the new bounds; LVGL focuses
153- // whatever widget is at that index. If nothing's there (empty list)
154- // we skip the focus call entirely.
155- const clamped = Math . min ( focusedIdxRef . current , orderedRefs . length - 1 ) ;
156- const idx = clamped < 0 ? 0 : clamped ;
157- focusedIdxRef . current = idx ;
155+ // Keep focus on the previously-focused item if it still exists;
156+ // otherwise fall back to the first focusable (non-separator) item.
157+ const focusableIds = flatRef . current
158+ . filter ( f => f . item . kind !== "separator" )
159+ . map ( f => f . item . id ) ;
160+ let targetId = focusedItemIdRef . current ;
161+ if ( ! focusableIds . includes ( targetId ) ) {
162+ targetId = focusableIds [ 0 ] ?? "" ;
163+ }
164+ focusedItemIdRef . current = targetId ;
158165 // Re-enable focus-event handling BEFORE the focus call so the
159166 // resulting LV_EVENT_FOCUSED updates the visual highlight.
160167 isRebuildingRef . current = false ;
161- if ( orderedRefs [ idx ] ) group . focus ( orderedRefs [ idx ] ) ;
162- // Defensive: synchronous setFocusedIdx in case the focus event
163- // didn't fire (e.g. orderedRefs[idx] was null).
164- setFocusedIdx ( idx ) ;
168+ const targetRef = targetId ? refsByIdRef . current . get ( targetId ) : null ;
169+ if ( targetRef ) group . focus ( targetRef ) ;
170+ // Defensive: synchronous setFocusedItemId in case the focus event
171+ // didn't fire (e.g. ref wasn't registered yet).
172+ setFocusedItemId ( targetId ) ;
165173 setKeyboardGroup ( group ) ;
166174 return ( ) => {
167175 setKeyboardGroup ( sinkGroup ?? null ) ;
@@ -181,7 +189,7 @@ export function Menu({ width, height, zoom, tree, onClose, sinkGroup }: MenuProp
181189 // place. At list ends the formula clamps (top: scroll=0; bottom:
182190 // scroll=maxScroll, focus rides the edge). Mirrors v0.5's MenuView UX.
183191 //
184- // Driven by focusedIdx (covers arrow nav + click), visibleKey (covers
192+ // Driven by focusedItemId (covers arrow nav + click), visibleKey (covers
185193 // submenu expand/collapse — inner View remounts so we resync scroll),
186194 // and height (window resize). useLayoutEffect (not useEffect) so the
187195 // scroll position is applied synchronously before the next LVGL paint,
@@ -192,32 +200,36 @@ export function Menu({ width, height, zoom, tree, onClose, sinkGroup }: MenuProp
192200 const view = innerViewRef . current ;
193201 if ( ! view || flat . length === 0 ) return ;
194202
195- // Item height depends on zoom (font + padding scale linearly).
196- // Re-measure whenever this effect runs to avoid a stale cache
197- // surviving a zoom change.
198- const firstRef = refsByIdRef . current . get ( flat [ 0 ] . item . id ) ;
203+ // Use the first focusable row for height measurement — separators
204+ // are thinner than items and would skew the cache.
205+ const firstItem = flat . find ( f => f . item . kind !== "separator" ) ;
206+ const firstRef = firstItem
207+ ? refsByIdRef . current . get ( firstItem . item . id ) : null ;
199208 if ( firstRef ?. getBoundingClientRect ) {
200209 itemHeightRef . current = firstRef . getBoundingClientRect ( ) . height ;
201210 }
202211 const itemH = itemHeightRef . current ;
203212 if ( itemH <= 0 ) return ;
204213
214+ const focusedFlatIdx = flat . findIndex ( f => f . item . id === focusedItemId ) ;
215+ if ( focusedFlatIdx < 0 ) return ;
216+
205217 const viewportH = height - titleRegionH ; // matches inner View's height calc
206218 const visibleRows = Math . max ( 1 , Math . floor ( viewportH / itemH ) ) ;
207219 const midpoint = Math . floor ( ( visibleRows - 1 ) / 2 ) ;
208220 const totalH = flat . length * itemH ;
209221 const maxScroll = Math . max ( 0 , totalH - viewportH ) ;
210222 const target = Math . min (
211- Math . max ( 0 , ( focusedIdx - midpoint ) * itemH ) ,
223+ Math . max ( 0 , ( focusedFlatIdx - midpoint ) * itemH ) ,
212224 maxScroll ,
213225 ) ;
214226 // LV_ANIM_OFF so rapid Down-holds don't lag behind the cursor.
215227 view . scrollToY ( target , false ) ;
216- // flat.length is captured implicitly via the focusedIdx /visibleKey deps
217- // — visibleKey changes whenever flat changes shape. `zoom` is in the
218- // deps because typography + viewport height both depend on it.
228+ // flat.length is captured implicitly via the focusedItemId /visibleKey
229+ // deps — visibleKey changes whenever flat changes shape. `zoom` is
230+ // in the deps because typography + viewport height both depend on it.
219231 // eslint-disable-next-line react-hooks/exhaustive-deps
220- } , [ focusedIdx , visibleKey , height , zoom ] ) ;
232+ } , [ focusedItemId , visibleKey , height , zoom ] ) ;
221233
222234 // Up / Down nav. No wrap-around — v0.5 doesn't wrap, and wrap on a
223235 // potentially long scrollable list is disorienting.
@@ -235,47 +247,51 @@ export function Menu({ width, height, zoom, tree, onClose, sinkGroup }: MenuProp
235247 // around the typed mismatch.
236248 const onItemKey = useCallback ( ( e : { key : number } ) => {
237249 ( e as any ) . stopPropagation ?.( ) ;
238- const refs = getOrderedRefs ( ) ;
239250 const group = groupRef . current ;
240- if ( ! group || refs . length === 0 ) return ;
241- const cur = focusedIdxRef . current ;
251+ const entries = flatRef . current ;
252+ if ( ! group || entries . length === 0 ) return ;
253+ const curId = focusedItemIdRef . current ;
254+ const cur = entries . findIndex ( f => f . item . id === curId ) ;
255+ if ( cur < 0 ) return ;
242256
243257 // Right/Left cycle the focused item's value (Zoom, MIDI routing,
244258 // Link group, LSDJ mode). Items without onCycle are no-op — focus
245259 // does NOT move on Right/Left, matching the v0.5 / desktop-app
246260 // convention where horizontal arrows manipulate the current row.
247261 if ( e . key === ELvKey . LV_KEY_RIGHT ) {
248- flatRef . current [ cur ] ?. item . onCycle ?.( 1 ) ;
262+ entries [ cur ] ?. item . onCycle ?.( 1 ) ;
249263 return ;
250264 }
251265 if ( e . key === ELvKey . LV_KEY_LEFT ) {
252- flatRef . current [ cur ] ?. item . onCycle ?.( - 1 ) ;
266+ entries [ cur ] ?. item . onCycle ?.( - 1 ) ;
253267 return ;
254268 }
255269
256- let next = cur ;
257- if ( e . key === ELvKey . LV_KEY_DOWN ) {
258- if ( cur >= refs . length - 1 ) return ;
259- next = cur + 1 ;
260- } else if ( e . key === ELvKey . LV_KEY_UP ) {
261- if ( cur <= 0 ) return ;
262- next = cur - 1 ;
263- } else {
264- return ;
270+ let dir : 1 | - 1 ;
271+ if ( e . key === ELvKey . LV_KEY_DOWN ) dir = 1 ;
272+ else if ( e . key === ELvKey . LV_KEY_UP ) dir = - 1 ;
273+ else return ;
274+ // Walk past any separator rows so navigation skips them.
275+ let next = cur + dir ;
276+ while ( next >= 0 && next < entries . length
277+ && entries [ next ] . item . kind === "separator" ) {
278+ next += dir ;
265279 }
280+ if ( next < 0 || next >= entries . length ) return ;
281+ const nextId = entries [ next ] . item . id ;
266282 // Set the ref synchronously BEFORE asking LVGL to move focus, so even
267283 // if the LV_EVENT_FOCUSED → onItemFocus chain races a subsequent
268284 // keystroke, the next onItemKey reads the correct `cur`.
269- focusedIdxRef . current = next ;
270- if ( refs [ next ] ) group . focus ( refs [ next ] ) ;
285+ focusedItemIdRef . current = nextId ;
286+ const nextRef = refsByIdRef . current . get ( nextId ) ;
287+ if ( nextRef ) group . focus ( nextRef ) ;
271288 } , [ ] ) ;
272289
273- const activate = useCallback ( ( i : number , item : MenuItem ) => {
290+ const activate = useCallback ( ( item : MenuItem ) => {
274291 // Remember which item the user is acting on so the rebuild useEffect
275292 // restores focus to the same row (the submenu header keeps the same
276- // index since it doesn't move when its children appear/disappear
277- // below it).
278- focusedIdxRef . current = i ;
293+ // id when its children appear/disappear below it).
294+ focusedItemIdRef . current = item . id ;
279295 if ( item . kind === "submenu" ) {
280296 // Suppress the stray LV_EVENT_FOCUSED events that fire during
281297 // the impending mutation phase — see isRebuildingRef comment.
@@ -356,7 +372,49 @@ export function Menu({ width, height, zoom, tree, onClose, sinkGroup }: MenuProp
356372 overflow : "auto" ,
357373 } }
358374 >
359- { flat . map ( ( { item, depth } , i ) => {
375+ { flat . map ( ( { item, depth } ) => {
376+ if ( item . kind === "separator" ) {
377+ // Thin dark-grey line between groups. Not focusable,
378+ // not clickable — naturally excluded from the LVGL
379+ // group since it registers no ref. Outer wrapper is
380+ // transparent and provides vertical breathing room
381+ // via padding (lvgljs has no `margin`); inner View
382+ // is the actual coloured line. Outer height must be
383+ // explicit — lvgljs Views without `height` stretch
384+ // to fill remaining flex space.
385+ const padTB = r ( 4 ) ;
386+ const lineH = Math . max ( 1 , r ( 1 ) ) ;
387+ const wrapperH = padTB * 2 + lineH ;
388+ return (
389+ < View
390+ key = { item . id }
391+ style = { {
392+ width : "100%" ,
393+ height : wrapperH ,
394+ "background-opacity" : 0 ,
395+ "border-width" : 0 ,
396+ "padding-left" : 0 ,
397+ "padding-right" : 0 ,
398+ "padding-top" : padTB ,
399+ "padding-bottom" : padTB ,
400+ } }
401+ >
402+ < View
403+ style = { {
404+ width : "100%" ,
405+ height : lineH ,
406+ "background-color" : "#444444" ,
407+ "background-opacity" : 255 ,
408+ "border-width" : 0 ,
409+ "padding-left" : 0 ,
410+ "padding-right" : 0 ,
411+ "padding-top" : 0 ,
412+ "padding-bottom" :0 ,
413+ } }
414+ />
415+ </ View >
416+ ) ;
417+ }
360418 const isSubmenu = item . kind === "submenu" ;
361419 const isOpen = isSubmenu && openItems . has ( item . id ) ;
362420 // Submenu items always show a hint glyph. `>` collapsed,
@@ -374,16 +432,16 @@ export function Menu({ width, height, zoom, tree, onClose, sinkGroup }: MenuProp
374432 else refsByIdRef . current . delete ( item . id ) ;
375433 } }
376434 style = { {
377- "text-color" : focusedIdx === i ? "#4fc3f7" : "#ffffff" ,
435+ "text-color" : focusedItemId === item . id ? "#4fc3f7" : "#ffffff" ,
378436 "font-size" : itemFont ,
379437 "padding-top" : itemPadVert ,
380438 "padding-bottom" : itemPadVert ,
381439 "padding-left" : basePadLeft + depth * indentStep ,
382440 "padding-right" : itemPadRight ,
383441 } }
384- onFocus = { ( ) => onItemFocus ( i ) }
442+ onFocus = { ( ) => onItemFocus ( item . id ) }
385443 onKey = { onItemKey }
386- onClick = { ( ) => activate ( i , item ) }
444+ onClick = { ( ) => activate ( item ) }
387445 >
388446 { label }
389447 </ TextAny >
0 commit comments