Skip to content

Commit 8b57b61

Browse files
committed
Add menu separators
1 parent 32d5187 commit 8b57b61

2 files changed

Lines changed: 143 additions & 64 deletions

File tree

ui/menu/Menu.tsx

Lines changed: 108 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)