Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

163. A menu bar owns its menus, and a value already outlives an opening

Date: 2026-08-20

Status

Accepted. Builds menubar and the half of §8’s accelerator that ADR-0106 deferred.

Context

docs/core-widgets.md §8 asks for two things that have been marked not built since ADR-0106, with the same sentence explaining both:

menubar — in-window horizontal bar; Alt-style keyboard activation; arrows navigate. Not built: it is the widget in this group that needs a menu to exist for longer than one opening, which is the same thing accelerator registration needs.

and, of item:

accelerator (displayed right-aligned and auto-registered in the window’s shortcut map) … The accelerator is displayed and not registered: a shortcut has to work while the menu is shut, and a menu is built when it opens and thrown away when it closes — registration needs something that owns menus for longer than one opening.

That reasoning was right about the problem and wrong about what the problem was made of. What is built and thrown away is the popup, not the menu. A Menu is a record — an ordinary value, described by the author, no different from a Button — and Menus.open builds a second tree from it to put in a window. The description was never short-lived. Nothing was ever holding it, which is a different complaint entirely, and the fix for it is a widget that does.

Decision

menubar is a row of items, and holding them is the whole mechanism.

new MenuBar(
        new Item("File").submenu(
                new Item("Open…", this::open).accelerator("Ctrl+O"),
                new Separator(),
                new Item("Quit", this::quit).accelerator("Ctrl+Q")),
        new Item("Edit").submenu(
                new Item("Undo", this::undo).accelerator("Ctrl+Z")));
menubar {
    item "File" {
        item press="app.open" accelerator="Ctrl+O" "Open…"
        separator
        item press="app.quit" accelerator="Ctrl+Q" "Quit"
    }
}

There is no new markup

A bar’s children are items, and an item containing items is a heading that opens a menu — which has been the submenu syntax since ADR-0106 (“a nested item is the submenu syntax and there is no submenu node to forget”). A menubar is a row of the thing a menu was already made of. Nothing about declaring one is new to learn, and no node was added to the catalog beyond the bar itself.

The accelerators are walked from the description, not from a window

Accelerators.in(widgets) walks the item tree and yields every Shortcut with the command behind it. MenuBarState binds them on build and gives them back on dispose. No menu is opened, and none needs to be — which is the claim, and which is why MenuBarTest’s central case fires Ctrl+O with nothing whatsoever on screen.

Three kinds of row are passed over, and each absence is an ordinary thing to write: no accelerator, no command (a row with a submenu leads somewhere rather than doing something), and disabled — because a greyed row that still fires on its key is worse than no accelerator at all.

An accelerator that does not parse is logged and skipped rather than thrown. It is a typo; it is already being drawn beside the row where somebody can see it; and a stylesheet error should not take a window down.

A heading is not a menu row

MenuTitle is a separate widget from Item rather than a flag on it, because the two answer the keyboard differently and that difference is what makes a bar a bar:

KeyIn a menu (Item)In a bar (MenuTitle)
Downmove to the next rowopen this menu
Rightopen this row’s submenumove to the next heading
Leftnothingmove to the previous heading

Left and Right are not the widget’s at all — MenuBarRow is a horizontal focus scope where a Menu is a vertical one (ADR-0078), so traversal comes free and the two arrows left over are the two the bar wants.

A heading also has none of the three things that make a row a row: no tick column, no accelerator on the right, no chevron.

Hovering opens, but only once something is open

A heading opens on a click. Once a menu is showing, every other heading opens on hover — which is what every desktop bar does, and why running along the bar with a menu down does not need a click per menu. Hovering with nothing open does nothing at all: a bar that dropped a menu because the pointer crossed it on the way somewhere else would be unusable.

F10, and why not Alt

§8 asks for “Alt-style keyboard activation”. A bare Alt tap is a modifier released with nothing in between, and a Shortcut here is a key plus modifiers — Key has no ALT to name, deliberately, because Shortcut’s own constructor refuses a shortcut that can never fire. So the binding is F10, which is the companion activator on every platform that has the Alt one, and which opens the first heading rather than merely focusing it: there is no Host.focus, and a bar that took F10 and did nothing visible would read as a broken binding rather than a missing one.

Alternatives considered

A MenuModel the window owns. What ADR-0106’s wording implies: a registry on the Host, menus put into it by name, the bar reading from it. It is a second place a menu can live, with its own lifetime and its own staleness, and every question it answers — what outlives an opening, what can be walked for accelerators — is answered by the value the author already wrote.

Register accelerators from Menus.open. The registration would then exist for exactly as long as the popup, which is the thing that does not work. Registering on open and not unregistering on close would leave a menu’s keys bound after a window forgot the menu existed.

Put the accelerator walk on Menu itself. menu.accelerators() reads well and puts a Host-facing concern on a value. Accelerators is a static utility for Menus’s own reason: binding needs a Host and a widget must not have one (ADR-0106).

A heading or menu-title markup node. More explicit than a nested item, and it would make a bar’s children a different shape from a menu’s for no gain — the nesting already means “opens this”.

Give Item a bar flag. One widget, two keyboard maps chosen by a boolean. Every method on it would then start by asking which one it is.

Consequences

Host grew removeShortcut. The router had it; the window’s front door did not, because until now nothing bound an accelerator it would later want back. The map is keyed by the shortcut and not by who bound it, so removing takes out whatever is bound to that key — including somebody else’s later binding. Two things claiming Ctrl+O is already a conflict the last registration wins; this is the same conflict at the other end, and it is written down rather than defended against, because defending would mean the map remembering owners and a menubar being the only thing that could ever use that.

A collision inside one bar is logged, and the later row wins. That is the map’s behaviour said out loud. Refusing the second would produce a menu whose second Ctrl+O silently does nothing.

Menus.open gained a placement overload. A heading’s menu hangs from the bar with no gap, where a context menu stands 4px off the pointer so as not to open underneath it.

The tests found a third stub Host, and there is now one. SelectTest and TourTest each carried a near-identical hand-written Host, and adding two methods to the interface would have meant editing both plus writing a third. TestHost is the shared one and both now extend it; it records popups, accelerators, overlays and anchors, and — like the real thing on SDL’s dummy driver — opens nothing, which is the branch ADR-0102 says a control has to survive.

Two things §8 asks for are still not built, and neither is a menu-bar problem. A bare Alt tap needs key-release tracking that Shortcut cannot express. And Left/Right do not move between menus while one is down: the open menu is a separate window with its own focus, so the bar cannot see the arrows — which is the same missing item-to-popup callback that has kept Left from closing a submenu since ADR-0112. Both are in TODO.md.

The bar was the first new widget drawn through ADR-0162. Six goldens, each also checked at 2× and 1.5×, on the day they were written rather than after somebody reports a HiDPI bug.

.open is the accent fill, not a stronger overlay. The first cut used --gb-overlay-active over :hover’s --gb-overlay-hover — 16% against 8% — and the golden showed the two are hard to tell apart, which is exactly the comparison a bar puts in front of somebody, since the hovered heading is usually the one next to the open one. Caught by looking at the image, which is what the image is for.