Desktop
35. The desktop window: focus, keys and overlays
HLD — Arrows show focus routing to semantic commands. Graph drawing still needs an accessible object-navigation path. Open the diagram for full size.
A desktop application is used with a keyboard and a mouse, often for hours at a time. Its users expect Tab to visit controls in order, arrow keys to move inside a group, Escape to close what they just opened, and menus and shortcuts that agree with each other. This chapter and the next describe how Surface provides these.
Reaktor's desktop app, Hangar, uses Surface for its shell and pane interactions. H1 built the behaviour of chapters 35 and 36, H2 built the parts of chapter 37, H3 adopted the shell, and H4–H5 adopted the shared pane compositions and all twelve modes. H6 retired the private behavior kits and independent theme recipes. Machine Signal now provides appearances, tokens and passive atoms; specialist engines remain Islands.
35.1 A window has one focus owner and one cursor
A desktop window has one focus owner and one cursor. The focus owner decides which element receives keys and typed text, and only one window at a time holds the system's keyboard focus. The cursor is the mouse pointer: the desktop delivers one pointer, never the several independent contacts a touch screen can.
Surface keeps that single focus owner by drawing every overlay inside the window. Compose's own popups, which Material's menus and tooltips use, are separate layers with focus owners of their own, and while a focusable popup is open, keys go to it instead of to the window's content.
Surface draws its menus, dialogs, sheets, popovers and tooltips in the window's overlay host instead: one layer of the window's own composition, drawn above its content (reaktor-surface-compose/.../Overlay.kt). An overlay then receives keys the way any other element does, and the Escape order of section 35.5 holds wherever focus is.
Hosts belong to a root. SurfaceEnvironmentProvider installs one overlay host and one command host (section 36.2) for the content it wraps, and an application installs it once at the root of each window (reaktor-surface-compose/.../SurfaceTheme.kt). Without a host, Overlay falls back to Compose's own popup, which still works but gives up the single focus owner.
An application can hold more than one root. Hangar previews the application it inspects inside its own window, and wraps that preview in a second SurfaceEnvironmentProvider with the bare theme, Surface's unstyled default. The previewed application's menus and dialogs draw in its own host, inside the preview, and Hangar's looks do not reach its controls. A modal it opens covers the preview, not Hangar's window.
35.2 Keys are portable values
Surface's kernels never read the platform. A key reaches a kernel as a plain value, and the platform's habits arrive as data (reaktor-surface/.../Keys.kt).
KeyName lists the keys Surface understands: letters, digits, F1 to F12, Enter, Escape, Tab, Space, Backspace, Delete, the arrows, Home, End, the page keys, a few punctuation keys and the context-menu key. A KeyStroke is one press of one of them: the key, the modifiers held, and the character it typed, if any. Typeahead reads the character.
A KeyConvention names the platform's habit. On a Mac the primary modifier is ⌘; on a PC it is Ctrl. Chords and their labels depend on it (section 36.1).
The Compose side converts events and asks the platform once (reaktor-surface-compose/.../Keys.kt):
stroke() returns null for keys Surface does not name, and those keys pass through untouched. platformKeyConvention() is the one place in Surface that asks. The JVM reads the operating system's name, iOS answers Mac, Android answers PC, and the web reads the browser's platform string. The answer becomes SurfaceEnvironment.keys, an ordinary environment value, so a test or a gallery story can force either convention on any machine.
35.3 A group is one Tab stop
Roving focus means a group of items is a single Tab stop, and the arrow keys move focus inside it. Typeahead means typing letters moves to the next item whose text starts with them. Tabs, radio groups, chips, menus and collections work this way.
One pure kernel provides both (reaktor-surface/.../Roving.kt):
The rules are short:
- Tab enters the group on its active item and leaves after one stop. In tabs and radio groups, while the group is not focused, the active item follows the selection, so Tab lands on the selected item. Chips do not: Tab lands on the chip that was last active.
- The arrows on the group's axis move to the next or previous enabled item. Left and Right swap in a right-to-left layout.
- Home and End go to the first and last enabled items.
- Disabled items are skipped. Wrapping is on for tabs, radios, chips and menus, and off for collections.
- An arrow off the group's axis, or one held with any modifier (⌘, Ctrl, Alt or Shift), is not handled. It bubbles on to the pane around the group.
Typeahead runs on a ticketed timer. Each printable character other than a space extends the typed text and restarts a 500 ms timer. A ticket is the number the kernel gives each timer; when a timer fires, the kernel ignores it unless its ticket is the latest, so an old timer can never clear newer typing.
The match ignores case. A single letter moves to the next enabled item after the active one whose text starts with it, so repeating one letter cycles through the items that start with it. Longer text is matched from the active item itself, which stays active while it still matches. Items opt in with a typeahead text, a parameter on group and menu items: the text matched when typing, not a label.
Selection follows focus where the pattern expects it. In Tabs (horizontal) and RadioGroup (both axes), moving focus selects the item. In a ToggleGroup of chips, focus moves without selecting, and Space toggles the focused chip. Each group runs its selection kernel and the roving kernel side by side, rather than one merged machine.
The adapter keeps items in visual order (reaktor-surface-compose/.../Roving.kt). Each item reports where it was placed, and the adapter orders items by line and then by reading direction, so an item that appears conditionally still takes its visual place. An item may take focus only while it is the active one, which is what makes the group one Tab stop.
Items are read by index. indexOf has a default that scans the items, which is all a short group's RovingList needs; a collection passes its item source, which answers indexOf from a map. A move, Home, End and reconciling after a change read only the items they step over, so moving through a 10,000-row list costs what moving through five tabs costs. A kernel test counts the reads on a 10,000-row source.
Buttons, items and rows draw focus only after keyboard input. A click focuses them without a ring, while Tab or an arrow shows one. Text fields are the exception: a field's focus feedback follows focus however it arrived, so a clicked field shows its ring. Machine Signal draws the ring as a 1 dp accent ring inside the control's bounds, so layout never moves, and section 10.2's rule that a theme cannot remove the indicator holds.
35.4 A modal holds focus, and every overlay gives it back
A focus trap keeps Tab inside an open modal. Focus return puts focus back where it was when an overlay closes. The overlay host owns both (reaktor-surface-compose/.../Overlay.kt).
A modal refuses focus entering anything it covers. While it is open, the host's content and every layer beneath the modal cancel focus that tries to enter them, so Compose's own wrap-around cycles Tab and Shift+Tab inside the modal. The content beneath is also hidden from assistive technology. Cancelling every exit from the modal would be the obvious trap, but in Compose it stops Tab at the last element instead of wrapping, and it would let a modal inside a previewed application hold the whole window.
The host saves focus whenever focus leaves its content. When a layer that held focus closes, the host waits one frame and restores the saved focus, unless focus is already back in the content or in another layer. A permanent focus restorer on the content would be simpler, but it would also redirect later requests, such as a click on a text field after using a menu, back to the old element.
A registered trigger wins. A menu, dialog or popover asks for focus on its trigger, the control that opened it, as it closes. Focus lands there first, and the host has nothing left to restore.
A menu without a trigger relies on the host's restore, and that restore reaches only one level deep. In a flat form that is enough: focus returns to the element itself, and because a Surface button takes focus when it is clicked (unless it also handles a long press), a picker gets focus back after its menu closes. Inside a workspace whose root is focusable, focus returns to that root instead, so a menu that opens deep inside a workspace needs a trigger. Hangar's pane composition passes a FocusRequester for the element that opened the Surface menu, which gets focus back as the menu closes. Its tables pass the row that was right-clicked. The former SignalContextMenu behavior wrapper was retired in H6.
A context menu has one built in: its area. The area remembers which of its elements last held focus, and closing the menu returns focus there (reaktor-surface-compose/.../ContextMenu.kt). Shift+F10 opens the menu at the focused element, so Escape lands back on it. A right-click opens the menu at the pointer without moving focus, so focus returns to the element that last held it, which need not be the one under the pointer.
35.5 Escape follows focus
Escape is handled by the first of these that wants it. This is the explicit dismissal priority that section 14.5 asks for.
| Order | Who | How |
|---|---|---|
| 1 | A tooltip that is showing | The overlay host hides it in its preview key handler, which runs before the focused element sees the key, and consumes the key |
| 2 | The overlay that holds focus, a submenu before its menu | The layer's own key handler. Layers are siblings in the host, so only the focused one sees the key |
| 3 | The focused element and its ancestors, innermost first: a field, then the pane around it | Ordinary key handlers, as the key bubbles up |
| 4 | The window's fallback | The application's window key handler |
| 5 | Platform back | OverlayBack, kept for Android's back gesture and as the last resort |
A showing tooltip comes first, so Escape always dismisses content that appeared on hover or focus, as WCAG 2.2 requires.
Because every overlay draws inside the window, this order holds wherever focus is. A visible pane can be unfocused while another pane receives the keyboard (section 13.4); Escape goes to the focused one.
Overlays close through their own key handlers, not through the platform's back dispatcher. On the desktop, Compose hands Escape to that dispatcher only after the focused element and the window's handler have declined it, and a headless test scene may never deliver it. Closing through focus makes Escape behave the same in a window and in a test.
35.6 Menus are built from the same kernels
A menu is a disclosure, open or closed, plus a roving group of items. MenuKernel follows the disclosure kernel's rules for opening and closing and returns focus to the same trigger part, hands movement to a roving kernel, and adds submenus (reaktor-surface/.../Menu.kt). Its policies are kernel parameters: the roving kernel it uses, and a 200 ms submenu delay.
- Opening. Down or Up on a focused trigger opens the menu on its first or last item. Enter, Space or a click toggles it, and the first item takes focus when the panel appears.
- Moving. Arrows, Home, End and typeahead come from the roving kernel, vertically and with wrap. Pointing at an item makes it active and focuses it, as native menus do. Focus is not drawn while the mouse is in use, so the item shows its hover tint and no ring.
- Choosing. The focused item's own click handler activates it, so the mouse, Enter and Space share one activation path.
- Submenus. Right opens a submenu on its first item, and Left closes it and returns focus to its parent item; the two swap in a right-to-left layout. Hovering a submenu item for 200 ms opens it, and hovering another item for as long closes it. That wait is hover intent: sweeping the pointer across a menu does not open every submenu on the way.
- Closing. Escape closes one level. Tab, a click outside and any choice close the whole chain, and focus returns as section 35.4 describes.
The adapter supplies the parts (reaktor-surface-compose/.../Menu.kt, .../Submenu.kt):
A menu has a Trigger and a Popup. Inside the popup there are Item, CheckItem, RadioItem, Submenu, Separator and Label. An item's content can hold an Indicator, which draws only while its item is checked. Check marks and chord hints are content, placed by the caller or by the command look of section 36.3.
Compose has no menu roles. Items are buttons; a radio item exposes its selected state and a check item its toggle state. Separators and labels are not focus targets, so the arrows pass over them. Select keeps its API and gains the menu's keys, because it is a menu whose options are radio items. Its options accept typeahead text, so displayed labels can differ from stable keys.
Menus are non-modal layers. Nothing beneath an open menu is trapped, so Tab closes the menu instead of cycling inside it. A submenu is one more layer above its parent, so menus stack.
ContextMenu opens without a trigger button. Its scope has an Area, a Popup and openAt(point). A secondary press in the area opens the menu at the pointer. Shift+F10, or the context-menu key, opens it at the focused element, which makes context menus reachable from the keyboard. openAt opens it at any point in the area. A collection opens one menu for all its rows the same way, instead of holding one menu per row (section 37.3).
35.7 Placement is a pure function
Where an overlay appears is decided by one function of integers, tested without composition (reaktor-surface-compose/.../Placement.kt). Menus, tooltips and popovers all use it.
The side is below, above, end or start of the anchor, and the alignment is its start, centre or end. The anchor is a rectangle, such as a button or a menu item, or a point, such as the pointer for a context menu. A point is a rectangle of zero size, so the menu's corner sits at the pointer. Menu takes either as an OverlayAnchor.
Each control states its preference. Context menus, popovers and Machine Signal's pane menus prefer to open below the anchor, aligned with its start edge, which is also Material's first choice. A submenu prefers the end side of its item, and a tooltip prefers below and centred. A plain Menu keeps Surface's original preference, below and end-aligned with a 6 dp gap, so existing menus did not move.
When the preference does not fit, three rules apply:
- Along the side's axis, try the preferred side, then the opposite side, then centred on the anchor's leading edge: its top edge for a menu below or above, its start edge for a submenu. If none fits, keep to the window edge on the anchor's half. This is the order Material's dropdown uses. An overlay too big for the window between its margins is centred in the window.
- Across, try the preferred alignment, then the other one, and only then push the overlay into the window. A centred tooltip that would overflow aligns with the anchor edge it would cross.
- Clamp whatever still does not fit into the window.
A margin keeps room clear at both ends of the side's axis. It is zero by default; Machine Signal's pane menus keep 48 dp, as Material does. A right-to-left layout mirrors the alignments and swaps the start and end sides.
35.8 Tooltips and popovers
A tooltip is a small kernel, TooltipKernel, and a wrapper around its anchor (reaktor-surface/.../Tooltip.kt, reaktor-surface-compose/.../Tooltip.kt). Its two timings are kernel parameters: a 350 ms delay and a 120 ms linger.
Its rules:
- One delay. The tip appears 350 ms after the pointer enters its anchor. That wait is the tooltip's hover intent, so a pointer passing over a toolbar on its way elsewhere shows nothing.
- A warm window. When the pointer left a showing tip's anchor less than 300 ms ago, the next tip appears at once, so moving along a row of buttons reads as one gesture. The host keeps that time and passes it in, so the kernel reads no clock.
- Keyboard focus. Focus that arrives by keyboard shows the tip at once.
- Hoverable. Leaving the anchor hides the tip 120 ms later, unless the pointer has moved onto the tip, so a long tip can be read.
- Presses. A short press hides the tip until the pointer leaves, so nothing flashes after a click. A press held for the delay shows it, which is how touch reveals a tip.
- Escape hides it, first in the order of section 35.5.
- Disabled anchors still show their tips, which is where an unavailable action explains itself.
The wrapper observes hover, presses and focus without consuming them, and never draws or resizes anything. The anchor's bounds and pixels are the same with or without a tooltip. The tip draws on the overlay host as a non-modal layer, placed by place.
Given a command, the tip adds the command's chord and, when the command is unavailable, its reason (section 36.1). The chord keeps its key order in a right-to-left layout: its label is wrapped in Unicode's left-to-right isolate marks, which lay a run out left to right whatever surrounds it, so ⌘↩ never reads ↩⌘. Menu items wrap their chords the same way.
A Popover is the click-opened sibling of a tooltip: a non-modal panel anchored to the box that holds its trigger, below and start-aligned by default (reaktor-surface-compose/.../Popover.kt). Activating the trigger, by a click, Enter or Space, opens it; it never opens on hover. Focus moves in once the panel is laid out and returns to the trigger when it closes. Escape, a click outside, or focus going back to the window's content dismisses it.
36. Commands, density and cost on the desktop
Chapter 35 routed keys and focus. This chapter covers what keys trigger, how a desktop look carries its density, and what a control costs while nothing happens. It ends with Hangar's shell adoption; chapter 37 then covers the parts a pane is made of.
36.1 A command is data
A command is plain data: an id, a label, an optional chord, an optional mark and its availability (reaktor-surface/.../Commands.kt). Because it is data, a native menu bar, a command palette and a tooltip can all show the same command.
A CommandSet lists commands and, separately, the menus that present some of them. A menu's entries are items, groups, captions and separators, and an item refers to its command by id. One command can therefore sit in the menu bar, the palette, a toolbar tooltip and a context menu without being copied. A command that no menu presents is keyboard-only: it still runs, and paths() lists it with an empty path, so a palette can show it.
A chord is portable. Primary is ⌘ on macOS and Ctrl elsewhere, while control always means the Control key. Platform covers the rare chord that differs by platform: full screen, for example, is ⌃⌘F on macOS and F11 elsewhere. label writes a chord the platform's way: Apple's glyphs in the order ⌃⌥⇧⌘ on macOS, and Ctrl+Alt+Shift+ spelled out elsewhere.
matches is exact for the convention. On a Mac, Ctrl+J does not run a ⌘J command, which leaves Control free for chords of its own. find returns the first available command a key press matches.
findings reports two problems before a user meets them: two commands on one chord (Clash), and a chord whose modifiers collapse on a PC (Collapsed), where primary and Control are the same key.
A mark shows state. A Choice is one of a group and is exposed as selected; a Check is an independent toggle. Unavailable(reason) keeps a command visible but disabled, and its reason is what the command's tooltip shows, in a menu or on a toolbar.
The label and the detail are data rather than composable parameters, because a native menu bar needs text and a palette searches it. Surface itself owns no commands: the application's own models produce them, and Surface routes keys to them and lists them.
36.2 Commands follow focus
A command set is attached to a part of the interface, its scope, with a modifier. The window's command host keeps track of the scopes (reaktor-surface-compose/.../Commands.kt).
A scope registers with the host, tracks whether focus is inside it, and matches chords on key-down as keys bubble up from the focused element. Four rules follow:
- The innermost focused scope sees a key first, so a pane's ⌘F wins over a window-level ⌘F.
- A focused text field handles its own editing keys before any scope sees them, because scopes listen as keys bubble, not before. ⌥← in a field still moves the caret by a word (Ctrl+← on a PC).
- A matched but unavailable command consumes the key and does nothing. It does not fall through to an outer scope's command on the same chord.
- Each chord runs exactly once.
focused lists the commands of the scopes on the focus path, innermost first, then the root's. That list is what a command palette can show.
dispatch is the window's fallback for keys that no focused element took, and it reaches only the root scopes. Overlay layers sit beside the content, not inside its scopes, so a chord pressed while a menu holds focus reaches the root through this fallback, once.
command looks a command up by id, which is how a tooltip finds a chord and a reason. invoke runs a command by id, as a palette does, and refuses an unavailable one. Every SurfaceEnvironmentProvider installs its own host, so a previewed application's scopes register with its own host, not with Hangar's.
36.3 Menus and menu bars draw from the same data
Inside the window, Commands renders a command set into menu parts (reaktor-surface-compose/.../Menu.kt):
A command with no mark becomes an item, a choice a radio item, a check a check item, a group a submenu, and captions and separators their own parts. An unavailable command becomes a disabled item whose tooltip gives its reason. Each item's key, and so its test tag, is the command's id, so a test finds a command by one name in every menu.
The item's look comes from the theme, through Appearance.Command. A collection or a pane can therefore render its actions without knowing which design language is in scope. Machine Signal's look, SignalCommand, draws the label, the chord and the mark.
The native menu bar stays in the application. Window chrome, menu bars, native decorations and where a window opens stay with the application and its platform. Surface supplies the model and one conversion on the JVM (reaktor-surface-compose/src/jvmMain/.../Commands.jvm.kt):
The application builds its bar from the same command set, so the bar, the palette and the in-window menus show the same chords. Each chord takes one route; on macOS the native bar handles its key equivalents before the window sees them.
Hangar's menus, native bar and palette read the same command model. H3 adopted platform primary chords and focused-editor routing; the shell tests check the shared actions and shortcuts. The shell's composition is described in section 36.6.
36.4 Density is a theme dimension
Density belongs to the theme, not to the layout. Section 12.1 names four dimensions of a theme: brand, design language, mode and density, and section 12.7 lets a scope override one of them alone, such as a compact inspector inside a comfortable product. Surface itself holds no density value yet. A design language carries density in its snapshot, and Machine Signal's two shipped combinations tie it to the variant.
Machine Signal, the design language of Reaktor's desktop tools, holds its density as a value, beside a variant (reaktor-ui/.../machinesignal/MachineSignalSurface.kt):
The variant carries colour and grammar. Board is Machine Signal's original look, and Editor is the code-editor look that Hangar uses. The density carries sizes: a Compact control is 28 dp high with 8 dp of padding, and a Comfortable one is 30 dp high with 16 dp.
One snapshot holds the colour roles, the metrics and the fonts, with an id such as machine-signal/editor/compact, so an inspector can name the decision set behind any look (section 12.3). MachineSignalSurface(variant, density) provides the snapshot and Machine Signal's looks to everything inside it.
The package declares the combinations it supports, as section 12.1 asks. It ships two, Board with Comfortable and Editor with Compact, both taken unchanged from the looks that shipped. MachineSignalSnapshot.of refuses any other pair with an error that names it, and a third pair would be a data change with its own evidence.
The accessor machineSignal fails loudly. Under a Machine Signal scope it returns that snapshot. Under the bare theme it returns Board, which is what an un-themed Machine Signal control has always drawn. Under another package's theme it throws ThemeMismatch (reaktor-surface/.../Recipe.kt), so a look drawn under the wrong theme fails in the first test that renders it, instead of drawing a plausible wrong colour.
The looks themselves are an open set of typed keys (reaktor-surface-compose/.../SurfaceTheme.kt):
Appearances maps keys to looks. A key names a slot and carries its bare default, and a key that no theme sets returns that default, so a control stays usable with no styling. A new control declares a key without changing Appearances or any theme; the separator, tooltip, popover and command looks were added this way, and another module can declare keys of its own. Machine Signal fills every key Surface defines.
36.5 What a control costs at rest
A dense desktop screen holds hundreds of controls, and almost all of them are idle at any moment. Surface's rule is that an idle control holds no coroutine.
Interactions arrive synchronously (reaktor-surface-compose/.../Inputs.kt):
The returned source passes presses, focus and hover straight to the control's machine from its emit and tryEmit, in arrival order. Foundation's clickable, focusable and hoverable deliver to it as to any interaction source, and keep their semantics and activation. Nothing waits on a flow.
Feedback starts on first use (reaktor-surface-compose/.../Appearance.kt). A control's press and focus values stay constant until a target changes. Only then does rememberFeedback start one animation, and its coroutine ends when the animation settles.
A machine holds a timer only while something is scheduled, such as typeahead or hover intent. A tooltip goes further and creates its machine on its first hover, press or focus, so a toolbar of tooltips holds nothing until someone uses it.
Before this, every Surface control held three suspended coroutines for its whole life: an interaction collector and two animations. SurfaceCostTest now composes 25 each of buttons, switches, checkboxes, list rows, menu triggers, context-menu areas and tooltip anchors, clicks one, and fails if any live coroutine is left behind.
There is one adapter, made cheap, rather than a second adapter for static or dense screens. Two adapters would make every author choose between paths whose behaviour must stay identical; one cheap path gives every control the saving, on phones too.
Hangar composes Surface controls directly; H6 retired the SignalButton and SubTab behavior wrappers. WrapperCostTest retains an independent frozen copy of the old kit as its reference. The final 2,000-button check takes 81.9 ms for that reference and 88.6 ms for Surface with Machine Signal, a ratio of 1.082 within the 1.1 budget. The reference is the independent path that section 26.2 asks an optimisation to keep.
Collections run one machine per collection, never one per row: a row runs no state machine and holds no coroutine (section 37.1).
36.6 The Hangar shell
H3 adopted H2’s parts, described in chapter 37, in Hangar’s shell: menus, palette and the native bar read the command model; the document strip uses the tab set; the tool rail uses vertical tabs; the drawer uses the pane host with graph-owned, workspace-scoped preferences. Escape follows the order in section 35.5. A collapsible drawer’s splitter hides it on Enter, and its tool button restores it at the retained size.
The full tab design, in which a tab is an independent navigation flow with its own history, retention and restore, and how many tabs show at once is a layout decision, waits for a real need.
37. Collections, panes and engines
HLD — Arrows follow pane planning and lifetime. Existing pane/tab machinery is a base, not proof of a packaged editor engine. Open the diagram for full size.
H2 built the parts a desktop pane is made of: lists, trees and tables that hold thousands of rows, splitters and pane hosts that lay a pane out from its own space, tab sets, notices, and islands around engines that bring their own input. Each is a pure kernel, a Compose adapter and named looks, like the controls of chapter 35. Hangar's shell and panes now use them. The Lab continues to qualify their looks and behavior (section 37.8).
37.1 A collection runs one machine, not one per row
A list, a tree or a table runs one machine for all its rows. Its kernel holds the active row, the anchor of a range selection, the typed text and the hovered row, and reads rows by index from an item source (reaktor-surface/.../Collection.kt, .../Sources.kt). The selection stays with the owner.
This is the one item source. It is read synchronously, by index, and names every row by a stable key, as section 35.3's RovingItems does. listSource builds one from a list, and treeSource from roots, children and the expanded keys, adding each row's depth and parent. A source that pages from a server waits until a pane needs one.
Tree also takes a tree source and onExpandedChange, and DataTable (section 37.2) takes columns. All three share one host over a lazy list (reaktor-surface-compose/.../Collections.kt).
Focus reaches rows that are not composed. The list composes only the rows on screen, but the kernel moves by index. The host carries out the kernel's focus and reveal requests itself: it scrolls a row into view and focuses it when it arrives. So End, PageDown and typeahead reach row 10,000, and Tab reaches the active row even after the wheel has scrolled it away.
One Tab stop. A collection is one Tab stop, even when its rows hold buttons. A row's content cannot take focus; its actions are reached through the row menu (section 37.3).
Keys and pointer. Keys follow APG's listbox and tree patterns:
- the arrows move, Home and End go to the ends, and PageUp and PageDown move by the rows that fit;
- Shift extends a range, and with the primary modifier the arrows move without changing the selection;
- Enter opens, Escape clears the selection, the primary modifier with A selects every row in a multiple selection, and typing jumps to a matching row;
- in a tree, Left and Right close and open branches.
The host reads keys before the lazy list does, because the list would otherwise scroll a page on PageDown without moving the active row. A click selects, Shift-click extends and primary-click toggles. A double-click opens without delaying the first click's selection, and a right-click selects its row unless that row is already selected.
Cost. Rows hold no machine, no coroutine and no pointer handler: one pointer node serves the whole collection. A hover or a selection change recomposes only the rows whose state changed, never their content, and scrolling composes only the rows that come into view (CollectionCostTest).
37.2 A table adds a header and columns
DataTable puts a header above the rows of section 37.1.
- Sorting stays with the owner. The header is one Tab stop, and the arrows move between its cells. A sortable header is a button whose state description says how it sorts. Enter, Space or a click moves it from ascending to descending to off. The table never reorders rows: the new
Sortgoes intoTableState.layout, and the owner sorts its source. - Widths. A column is fixed or takes a share of what the fixed columns leave. Dragging a header's end edge resizes its column, and a double-click there restores the declared width. "Wider", "Narrower" and "Reset width" do the same for assistive technology. A width the user sets is kept in
TableState.layout, in dp, so an owner can store it. - One width rule. The header and every row are laid out from the same column widths, so the columns always line up, and a cell costs no layout node of its own. A cell is the owner's content: the table gives it the column's width and centres it vertically.
- Wide tables. When the columns are wider than the frame, the table scrolls sideways, and Left and Right scroll it.
- State. A busy table draws a thin progress line under its header and keeps its rows usable. An empty one draws its owner's empty content, usually a state view (section 37.6).
Frame time. The Lab's table holds 10,000 rows shaped like a network log. A test scrolls it three rows a frame with the wheel and pages it with PageDown, and runs the same script over the same rows in the table Hangar used before. The new table's frames may take at most a tenth longer than the old table's, and on a machine where the old table fits its frames into 60 or 120 Hz, the new one must too (CollectionFrameTest).
37.3 One menu per collection
A collection given row actions holds one context menu for all its rows. It opens at the pointer for a right-click, under the active row for Shift+F10 or the context-menu key, and under a row's ⋯ button, ItemScope.MenuTrigger. The menu draws the command set with the renderer of section 36.3, so checks, separators, unavailable reasons and chord hints come with it. The same commands work as chords while the collection has focus. Closing the menu gives the row its focus back.
37.4 A pane plans from its own space
Four layout rules hold:
- a pane plans from its container's space, never the window's;
- route and selection live above the plan, so a resize never remounts a form, loses scroll or adds history;
- every visible pane names its focus target, and a hidden pane holds no active input;
- content takes part through its minimum and preferred sizes, not through fixed breakpoints.
The pane plan is pure (reaktor-surface/.../Panes.kt):
It sizes a pane's start, end and bottom regions from the container's size, each region's preferred, minimum and maximum size and collapse order, and the user's own sizes and hidden regions. When space runs short, regions shrink toward their minimums and then collapse, lowest order first. Collapsing undoes itself when space returns; hiding is the user's choice and stays. Minimums grow with text scale.
PaneHost lays a pane out from that plan, measured against its own container, so each region sees its own size and never the window's. It draws a splitter beside resizable regions; a region with equal minimum and maximum sizes has no handle. A region’s optional label names its handle, and collapsible allows Enter to hide it while retaining its size in PanePreferences. F6 and Shift+F6 move focus between the regions, and each region gives focus back to where it was. Split is the two-part form, with the fraction model Hangar used before.
Splitters follow APG's window splitter. The arrows move an edge by 16 dp, Home and End go to its limits, a double-click or the "Reset size" action restores its first size, and right to left mirrors the keys. Sizes are the owner's: PaneHost reports PanePreferences, and Surface stores nothing. A header outside the host can pass the same PaneHostFocus to the host and route F6 through cycle(backward), preserving one region order and focus restoration.
37.5 A tab set manages documents
A tab set is a strip of tabs the user manages, such as an editor's open documents. It is not a view selector: Tabs chooses one of a fixed set of views.
- Identity is a key, never a position, and order is data.
- The strip owns no state: which tabs exist, and which is selected after a close, stay with the owner.
- The strip is one Tab stop. The arrows move focus without switching, and Enter, Space or a click switches.
- A tab can close when its content draws
TabScope.Close. The close button takes no Tab stop, is a button of its own for pointers and screen readers, and gives its tab a "Close" action. Delete closes the focused tab and moves focus to its neighbour. - Tabs that overflow the strip scroll, and a new selection is scrolled into view.
- The optional
textfunction supplies typeahead labels while preserving stable keys.Tabsalso accepts an optionalaxisfor vertical view selectors; its default remains horizontal.
37.6 Notices: state views, busy regions and toasts
A state view says why a region has no useful content, with one sentence of reason and at most one action:
Unavailable means this target, build or connection lacks the capability. Gone means the subject no longer exists. Stale marks data that is still shown, and Machine Signal draws it as a strip above that data. Loading exposes an indeterminate progress, and the reasons of Loading, Failed and Stale are polite live regions, so a screen reader hears the change without being interrupted.
A busy region keeps its content and stays usable. Modifier.busy gives it the state description "Busy".
Toasts draw in a non-modal layer at the overlay host's bottom end, above every pane and without taking focus, and clicks beside a toast reach the content beneath. Hovering a toast holds it, and leaving restarts its full time. With no overlay host, a toast draws in place.
37.7 An island is the boundary around an engine
An engine is a component with its own rendering and input, such as the code editor or the Blueprint canvas. An island wraps one without changing it:
- One Tab stop. The island is a focus group. An engine with no focus target of its own, such as the canvas, gets the island itself as its target, with a focus ring.
- A way out. Escape goes to the engine first. A Tab or Shift+Tab right after it leaves the island, and the engine never sees that Tab. The editor takes Tab for indentation, so without this a keyboard user could enter it but never leave. An Escape the engine does not use goes on to the pane.
- Commands. The island's available commands are chords while focus is inside, and actions that assistive technology, flows and agents can run.
- Lifetime and view state. Hiding an island removes it from composition and releases what it composed. The engine's view state, such as a camera, stays with its owner.
CodeView (reaktor-ui/.../code/CodeView.kt) is the read-only code viewer inside an island whose commands are Copy and Find.
37.8 Every part has an id, and every story qualifies
Automation ids come from named scopes:
Scopes nest with /, and a part's id is its key inside the scope: a row is files/row/<key>, an item of the menu it opens files/menu/<command>, and a tab docs/<key>. Ids ignore text, text scale and direction, so flows and agents keep working when those change. With no scope a part keeps its bare key.
The Lab declares its stories as data: each has states, a size and the appearance keys it shows. A language without a look for one of those keys draws the part bare and says so. LabBench draws one story in one state, language and environment, with an event log, and qualification flows drive it with the runner that drives Hangar's own flows.
Qualification. SurfaceQualificationTest renders every story state in every language and environment (default, text at 160 % and right to left), 846 cells in all. In each it checks:
- the cell paints;
- no two nodes share an id;
- every clickable node has a name or a role;
- where the cell can take focus, one Tab focuses something, which then shows focus.
It writes a contact sheet per story. The final migration gate reports zero new findings and 24 known Human Signal tab-focus findings. Those visible changes remain for the owner.
The 10,000-row table check measures 120 warm-up and 600 timed frames in three rounds: wheel p95 is 2.5 ms and keyboard p95 is 12.9 ms, within 8.3/16.7 ms. The controller harness sees ten Surface machines for both 1,000 and 8,000 rows, constant across settled frames and zero retained after disposal. It measures the bare BestBuds map separately; its zero count does not describe the whole shell. A separate camera/render frame probe measures the shipped BestBuds and Hangar maps on identical source shapes in three alternating H5/current JVM rounds. Median p95 is 4.5/9.9 ms, within the 1.1 relative and calibrated 8.3/16.7 ms budgets. Offscreen frame timings and actual native startup are recorded separately.
37.9 Container sizes
Pane plans are tested at these sizes:
- widths: 400, 600, 700, 720, 840, 1,100, 1,120, 1,512, 1,920 and 2,560 dp: two legacy desktop panes, the 600 and 840 dp edges of Android's window-size classes,
presentationPlan's 720 and 1,120, Hangar's three baseline widths, and a large display; - heights: 480, 800, 1,000 and 1,440 dp;
- text scale: 1.0 and 1.6.
At every one of them, no shown region falls below its minimum, and main never falls below its minimum while a collapsible region is still shown. Widening never collapses a region that was shown, the same inputs give the same plan, and a user's size survives a narrow plan.
37.9 Hangar's shared pane compositions
Hangar composes Surface rather than adding product behaviour to its kernel. Its shared PaneFrame has one 32 dp header, read policy/status, provenance, Refresh, filters, body, inspector and status slots. The inspector uses PaneHost with a default width of 360 dp and a 280–720 range. Nested hosts share the outer host's F6 order. Close hides the inspector while retaining the subject; Escape clears selection.
The product table adapter retains each pane's cell content and stable row ids over Surface DataTable. It supplies row and overflow actions from one list, ascending/descending/off sorting, resizing, activation and copy feedback. The search field debounces changes by 300 ms, follows the existing query grammar, takes Primary-F and clears on Escape. A same-scope refresh keeps successful data marked refreshing; changing scope clears it. View, table and inspector preferences belong to the existing desktop graph's workspace record. Restoring default layout resets sizes while keeping the view and sort.
Plans follow the control plane's effect class. Production, credential and irreversible plans ask for the operation id; device and non-production writes use a plan without a phrase. Approval records the operator already supplied by the host and grants no new authority. A second approver remains a stated desktop limitation with the existing CLI route. These parts are qualified in the Lab; each pane adopts them behind its own parity gate.