Skip to content

Commit e1871df

Browse files
Merge pull request #20 from RtlZeroMemory/feat/dropdown-highlight-mouse
Dropdown: highlight selected row + mouse selection
2 parents 1e93e80 + 47b7f6b commit e1871df

File tree

9 files changed

+309
-7
lines changed

9 files changed

+309
-7
lines changed

docs/widgets/dropdown.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ ui.dropdown({
2323
## Behavior
2424

2525
- **Arrow keys** navigate items. **Enter** selects the highlighted item.
26+
- The current selection is visually highlighted.
2627
- **Mouse click** on an item selects it and fires the `onSelect` callback.
27-
- **Clicking outside** the dropdown closes it (via the layer backdrop).
28+
- **Clicking outside** the dropdown closes it (calls `onClose`).
2829

2930
## Notes
3031

3132
- Use `anchorId` to position the dropdown relative to an element in the layout tree.
3233
- Render dropdowns inside `ui.layers(...)` so they stack above base UI.
33-

packages/core/src/app/__tests__/widgetRenderer.integration.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ function mouseDownEvent(x: number, y: number): ZrevEvent {
3232
};
3333
}
3434

35+
function mouseEvent(x: number, y: number, mouseKind: 1 | 2 | 3 | 4 | 5): ZrevEvent {
36+
return {
37+
kind: "mouse",
38+
timeMs: 0,
39+
x,
40+
y,
41+
mouseKind,
42+
mods: 0,
43+
buttons: 0,
44+
wheelX: 0,
45+
wheelY: 0,
46+
};
47+
}
48+
3549
function createNoopBackend(): RuntimeBackend {
3650
return {
3751
start: async () => {},
@@ -418,6 +432,99 @@ describe("WidgetRenderer integration battery", () => {
418432
assert.deepEqual(activated, ["t0"]);
419433
});
420434

435+
test("dropdown mouse click selects item and closes", () => {
436+
const backend = createNoopBackend();
437+
const renderer = new WidgetRenderer<void>({
438+
backend,
439+
requestRender: () => {},
440+
});
441+
442+
const events: string[] = [];
443+
444+
const vnode = ui.layers([
445+
ui.column({}, [ui.button({ id: "anchor", label: "Menu" })]),
446+
ui.dropdown({
447+
id: "dd",
448+
anchorId: "anchor",
449+
position: "below-start",
450+
items: [
451+
{ id: "one", label: "One" },
452+
{ id: "two", label: "Two" },
453+
],
454+
onSelect: (item) => events.push(`select:${item.id}`),
455+
onClose: () => events.push("close"),
456+
}),
457+
]);
458+
459+
const res = renderer.submitFrame(
460+
() => vnode,
461+
undefined,
462+
{ cols: 40, rows: 10 },
463+
defaultTheme,
464+
noRenderHooks(),
465+
);
466+
assert.ok(res.ok);
467+
468+
renderer.routeEngineEvent(mouseEvent(2, 3, 3));
469+
renderer.routeEngineEvent(mouseEvent(2, 3, 4));
470+
471+
assert.deepEqual(events, ["select:two", "close"]);
472+
});
473+
474+
test("dropdown mouse up does not select different item after reorder", () => {
475+
const backend = createNoopBackend();
476+
const renderer = new WidgetRenderer<void>({
477+
backend,
478+
requestRender: () => {},
479+
});
480+
481+
const events: string[] = [];
482+
let items: readonly { id: string; label: string }[] = [
483+
{ id: "one", label: "One" },
484+
{ id: "two", label: "Two" },
485+
];
486+
487+
const view = () =>
488+
ui.layers([
489+
ui.column({}, [ui.button({ id: "anchor", label: "Menu" })]),
490+
ui.dropdown({
491+
id: "dd",
492+
anchorId: "anchor",
493+
position: "below-start",
494+
items,
495+
onSelect: (item) => events.push(`select:${item.id}`),
496+
onClose: () => events.push("close"),
497+
}),
498+
]);
499+
500+
const first = renderer.submitFrame(
501+
() => view(),
502+
undefined,
503+
{ cols: 40, rows: 10 },
504+
defaultTheme,
505+
noRenderHooks(),
506+
);
507+
assert.ok(first.ok);
508+
509+
renderer.routeEngineEvent(mouseEvent(2, 3, 3));
510+
511+
items = [
512+
{ id: "two", label: "Two" },
513+
{ id: "one", label: "One" },
514+
];
515+
const second = renderer.submitFrame(
516+
() => view(),
517+
undefined,
518+
{ cols: 40, rows: 10 },
519+
defaultTheme,
520+
noRenderHooks(),
521+
);
522+
assert.ok(second.ok);
523+
524+
renderer.routeEngineEvent(mouseEvent(2, 3, 4));
525+
assert.deepEqual(events, []);
526+
});
527+
421528
test("virtualList routing updates selection and activates on Enter", () => {
422529
const backend = createNoopBackend();
423530
const renderer = new WidgetRenderer<void>({

packages/core/src/app/widgetRenderer.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
} from "../keybindings/keyCodes.js";
5454
import { hitTestFocusable } from "../layout/hitTest.js";
5555
import { type LayoutTree, layout } from "../layout/layout.js";
56+
import { calculateAnchorPosition } from "../layout/positioning.js";
5657
import { measureTextCells } from "../layout/textMeasure.js";
5758
import type { Rect } from "../layout/types.js";
5859
import { PERF_DETAIL_ENABLED, PERF_ENABLED, perfMarkEnd, perfMarkStart } from "../perf/perf.js";
@@ -247,6 +248,7 @@ export class WidgetRenderer<S> {
247248
private committedRoot: RuntimeInstance | null = null;
248249
private layoutTree: LayoutTree | null = null;
249250
private renderTick = 0;
251+
private lastViewport: Viewport = Object.freeze({ cols: 0, rows: 0 });
250252

251253
/* --- Focus/Interaction State --- */
252254
private focusState: FocusManagerState = createFocusManagerState();
@@ -256,6 +258,7 @@ export class WidgetRenderer<S> {
256258
private baseEnabledById: ReadonlyMap<string, boolean> = new Map<string, boolean>();
257259
private pressableIds: ReadonlySet<string> = new Set<string>();
258260
private pressedId: string | null = null;
261+
private pressedDropdown: Readonly<{ id: string; itemId: string }> | null = null;
259262
private pressedVirtualList: Readonly<{ id: string; index: number }> | null = null;
260263
private pressedTable: Readonly<{ id: string; rowIndex: number }> | null = null;
261264
private pressedTableHeader: Readonly<{ id: string; columnIndex: number }> | null = null;
@@ -528,6 +531,96 @@ export class WidgetRenderer<S> {
528531
}
529532

530533
if (event.kind === "mouse") {
534+
const topDropdownId =
535+
this.dropdownStack.length > 0
536+
? (this.dropdownStack[this.dropdownStack.length - 1] ?? null)
537+
: null;
538+
if (topDropdownId) {
539+
const dropdown = this.dropdownById.get(topDropdownId);
540+
const dropdownRect = dropdown ? this.computeDropdownRect(dropdown) : null;
541+
if (dropdown && dropdownRect && dropdownRect.w > 0 && dropdownRect.h > 0) {
542+
const inside =
543+
event.x >= dropdownRect.x &&
544+
event.x < dropdownRect.x + dropdownRect.w &&
545+
event.y >= dropdownRect.y &&
546+
event.y < dropdownRect.y + dropdownRect.h;
547+
548+
const contentX = dropdownRect.x + 1;
549+
const contentY = dropdownRect.y + 1;
550+
const contentW = Math.max(0, dropdownRect.w - 2);
551+
const contentH = Math.max(0, dropdownRect.h - 2);
552+
const inContent =
553+
event.x >= contentX &&
554+
event.x < contentX + contentW &&
555+
event.y >= contentY &&
556+
event.y < contentY + contentH;
557+
const itemIndex = inContent ? event.y - contentY : null;
558+
559+
const MOUSE_KIND_DOWN = 3;
560+
const MOUSE_KIND_UP = 4;
561+
562+
if (event.mouseKind === MOUSE_KIND_DOWN) {
563+
this.pressedDropdown = null;
564+
565+
if (!inside) {
566+
if (dropdown.onClose) {
567+
try {
568+
dropdown.onClose();
569+
} catch {
570+
// Swallow close callback errors to preserve routing determinism.
571+
}
572+
}
573+
return ROUTE_RENDER;
574+
}
575+
576+
if (itemIndex !== null && itemIndex >= 0 && itemIndex < dropdown.items.length) {
577+
const item = dropdown.items[itemIndex];
578+
if (item && !item.divider && item.disabled !== true) {
579+
const prevSelected = this.dropdownSelectedIndexById.get(topDropdownId) ?? 0;
580+
this.dropdownSelectedIndexById.set(topDropdownId, itemIndex);
581+
this.pressedDropdown = Object.freeze({ id: topDropdownId, itemId: item.id });
582+
return Object.freeze({ needsRender: itemIndex !== prevSelected });
583+
}
584+
}
585+
586+
// Click inside dropdown but not on a selectable item: consume.
587+
return ROUTE_NO_RENDER;
588+
}
589+
590+
if (event.mouseKind === MOUSE_KIND_UP) {
591+
const pressed = this.pressedDropdown;
592+
this.pressedDropdown = null;
593+
594+
if (pressed && pressed.id === topDropdownId && itemIndex !== null) {
595+
const item = dropdown.items[itemIndex];
596+
if (item && item.id === pressed.itemId && !item.divider && item.disabled !== true) {
597+
if (dropdown.onSelect) {
598+
try {
599+
dropdown.onSelect(item);
600+
} catch {
601+
// Swallow select callback errors to preserve routing determinism.
602+
}
603+
}
604+
if (dropdown.onClose) {
605+
try {
606+
dropdown.onClose();
607+
} catch {
608+
// Swallow close callback errors to preserve routing determinism.
609+
}
610+
}
611+
return ROUTE_RENDER;
612+
}
613+
}
614+
615+
// Mouse up while dropdown is open: consume.
616+
return ROUTE_NO_RENDER;
617+
}
618+
619+
// Dropdown open: block mouse events to lower layers.
620+
return ROUTE_NO_RENDER;
621+
}
622+
}
623+
531624
const hit = hitTestLayers(this.layerRegistry, event.x, event.y);
532625
if (hit.blocked) {
533626
const blocking = hit.blockingLayer;
@@ -1827,6 +1920,43 @@ export class WidgetRenderer<S> {
18271920
}
18281921
}
18291922

1923+
private computeDropdownRect(props: DropdownProps): Rect | null {
1924+
const viewport = this.lastViewport;
1925+
if (viewport.cols <= 0 || viewport.rows <= 0) return null;
1926+
1927+
const anchor = this.rectById.get(props.anchorId) ?? null;
1928+
if (!anchor) return null;
1929+
1930+
const items = Array.isArray(props.items) ? props.items : [];
1931+
let maxLabelW = 0;
1932+
let maxShortcutW = 0;
1933+
for (const item of items) {
1934+
if (!item || item.divider) continue;
1935+
const labelW = measureTextCells(item.label);
1936+
if (labelW > maxLabelW) maxLabelW = labelW;
1937+
const shortcut = item.shortcut;
1938+
if (shortcut && shortcut.length > 0) {
1939+
const shortcutW = measureTextCells(shortcut);
1940+
if (shortcutW > maxShortcutW) maxShortcutW = shortcutW;
1941+
}
1942+
}
1943+
1944+
const gapW = maxShortcutW > 0 ? 1 : 0;
1945+
const contentW = Math.max(1, maxLabelW + gapW + maxShortcutW);
1946+
const totalW = Math.max(2, contentW + 2); // +2 for border
1947+
const totalH = Math.max(2, items.length + 2); // +2 for border
1948+
1949+
const pos = calculateAnchorPosition({
1950+
anchor,
1951+
overlaySize: { w: totalW, h: totalH },
1952+
position: props.position ?? "below-start",
1953+
viewport: { x: 0, y: 0, width: viewport.cols, height: viewport.rows },
1954+
gap: 0,
1955+
flip: true,
1956+
});
1957+
return pos.rect;
1958+
}
1959+
18301960
/**
18311961
* Execute view function, commit tree, compute layout, and render to drawlist.
18321962
*
@@ -1860,6 +1990,7 @@ export class WidgetRenderer<S> {
18601990
};
18611991
}
18621992

1993+
this.lastViewport = viewport;
18631994
this.builder.reset();
18641995

18651996
let entered = false;
@@ -2643,6 +2774,7 @@ export class WidgetRenderer<S> {
26432774
commandPaletteItemsById: this.commandPaletteItemsById,
26442775
commandPaletteLoadingById: this.commandPaletteLoadingById,
26452776
toolApprovalFocusedActionById: this.toolApprovalFocusedActionById,
2777+
dropdownSelectedIndexById: this.dropdownSelectedIndexById,
26462778
diffViewerFocusedHunkById: this.diffViewerFocusedHunkById,
26472779
diffViewerExpandedHunksById: this.diffViewerExpandedHunksById,
26482780
layoutIndex: this._pooledRectByInstanceId,

packages/core/src/renderer/__tests__/render.golden.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,11 @@ function layoutTree(vnode: VNode) {
106106
return res.value;
107107
}
108108

109-
function renderBytes(vnode: VNode, focusState: FocusState): Uint8Array {
109+
function renderBytes(
110+
vnode: VNode,
111+
focusState: FocusState,
112+
opts: Readonly<{ dropdownSelectedIndexById?: ReadonlyMap<string, number> }> = {},
113+
): Uint8Array {
110114
const committed = commitTree(vnode);
111115
const lt = layoutTree(committed.vnode);
112116

@@ -117,6 +121,9 @@ function renderBytes(vnode: VNode, focusState: FocusState): Uint8Array {
117121
viewport: { cols: 80, rows: 25 },
118122
focusState,
119123
builder: b,
124+
...(opts.dropdownSelectedIndexById
125+
? { dropdownSelectedIndexById: opts.dropdownSelectedIndexById }
126+
: {}),
120127
});
121128
const built = b.build();
122129
assert.equal(built.ok, true);
@@ -347,6 +354,40 @@ describe("renderer - widget tree to deterministic ZRDL bytes", () => {
347354
assertBytesEqual(actual, expected, "layer_backdrop_opaque.bin");
348355
});
349356

357+
test("dropdown_selected_row.bin", async () => {
358+
const expected = await load("dropdown_selected_row.bin");
359+
360+
const anchor: VNode = { kind: "button", props: { id: "anchor", label: "Menu" } };
361+
const dropdown: VNode = {
362+
kind: "dropdown",
363+
props: {
364+
id: "dd",
365+
anchorId: "anchor",
366+
position: "below-start",
367+
items: Object.freeze([
368+
{ id: "one", label: "One" },
369+
{ id: "two", label: "Two", shortcut: "Ctrl+T" },
370+
{ id: "three", label: "Three" },
371+
]),
372+
},
373+
};
374+
375+
const vnode: VNode = {
376+
kind: "layers",
377+
props: {},
378+
children: Object.freeze([
379+
{ kind: "column", props: {}, children: Object.freeze([anchor]) },
380+
dropdown,
381+
]),
382+
};
383+
384+
const selectedIndexById = new Map<string, number>([["dd", 1]]);
385+
const actual = renderBytes(vnode, Object.freeze({ focusedId: null }), {
386+
dropdownSelectedIndexById: selectedIndexById,
387+
});
388+
assertBytesEqual(actual, expected, "dropdown_selected_row.bin");
389+
});
390+
350391
test("button text is width-clamped in narrow layouts", () => {
351392
const vnode: VNode = { kind: "button", props: { id: "narrow", label: "ABCDEFGHI" } };
352393
const committed = commitTree(vnode);

packages/core/src/renderer/renderToDrawlist.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function renderToDrawlist(params: RenderToDrawlistParams): void {
7272
params.commandPaletteItemsById,
7373
params.commandPaletteLoadingById,
7474
params.toolApprovalFocusedActionById,
75+
params.dropdownSelectedIndexById,
7576
params.diffViewerFocusedHunkById,
7677
params.diffViewerExpandedHunksById,
7778
params.tableRenderCacheById,

0 commit comments

Comments
 (0)