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

Input and focus

Pointer, wheel and keyboard events route through one router that remembers who is hovered, pressed and focused, against the frame the user is looking at.

By the end of this chapter you can bind an accelerator, make a composite one Tab stop, read a drag without remembering where it started, take a dropped file, open a context menu from the keyboard, and know what a custom widget implements to take part.

@Override public void start(Host host) {
    host.shortcut(Shortcut.primary(Key.S), this::save);        // Cmd+S or Ctrl+S
    host.shortcut("Ctrl+Shift+Z", this::redo);
    host.window().onFileDrop(drop -> open(drop.first(), drop.at()));
    Menus.contextMenus(host, Map.of("rows", rowMenu()));
}

How an event travels

Dispatch is capture, then target, then bubble. A Handles widget sees the event in onPointerCapture root-first, then the deepest node under the pointer sees it in onPointer, then each ancestor does. consume() stops it at any step. The target stays the same through all three phases, so an ancestor can tell what happened to it from what happened below it.

The router holds what input has to remember between frames against elements, because a widget is rebuilt constantly and could not remember any of it. :hover, :active, :focus and :focus-visible are set on elements by the router, which is why they survive a rebuild.

Hit testing runs against the snapshot taken while painting, not a fresh layout pass. A pointer event is about what the user can see, and that is the last frame drawn (ADR-0054). A transform the painter applied is inverted once, from the very matrix the rasterizer used, so a scaled control responds where it is drawn.

Kinds

PointerEvent.KindWhen
MOVEDthe pointer moved with no button change
PRESSED, RELEASEDa button went down or came up, with button() and clickCount()
ENTERED, EXITEDsynthetic, derived from pointer flow
CLICKEDsynthetic: a press and its release both landed on this node
WHEELthe wheel turned or a touchpad scrolled

A press that is dragged off and let go elsewhere still releases, so the captor can stop looking pressed, and is not a click. Cancelling a click by dragging off is a gesture people rely on.

A press captures the pointer

A press takes an implicit capture until the release, so a drag that leaves a widget still reaches it and :active cannot get stuck. That is what makes a slider work (ADR-0058). :hover keeps following the pointer regardless: capture decides who is told, not what is highlighted. router.capturePointer(element) takes a capture that outlives the release, for the rare widget that needs one.

Gestures: where the drag started

@Override public void onPointer(PointerEvent event) {
    if (event.kind() == PointerEvent.Kind.MOVED && !Float.isNaN(event.dragX())) {
        var fraction = event.local().fractionX();          // 0..1 along my own box
        onChange.accept(min + fraction * (max - min));
    }
}

A widget is a value rebuilt every frame and cannot remember where a drag began. The router can, and reports it on every event of the gesture: pressX() and pressY() are where the button went down, and dragX() and dragY() are how far the pointer has travelled since, NaN when no button is held (ADR-0075).

local() is the pointer in the widget’s own box, with fractionX() and fractionY() clamped to 0..1. A control whose hit target is bigger than the thing being pointed along, a slider with a readout beside its track, names the part to measure against with Handles.localPart() (ADR-0079). A control whose drag is a rate rather than a position, a knob, answers gestureAnchor() once on the press and gets it back as anchor() on every event after. A canvas reads content() instead of local(): it is measured from inside the padding, where the painter draws.

A drag held at the edge of a viewport carries the viewport on, and the selection with it (ADR-0500).

The wheel is lines

@Override public void onPointer(PointerEvent event) {
    if (event.kind() == PointerEvent.Kind.WHEEL) {
        scrollBy(event.deltaY() * lineHeight);
        event.consume();          // and the page behind does not lurch
    }
}

Wheel deltas are in lines, fractional, positive down and right. A touchpad sends a fraction of a detent per frame and the fraction is preserved in deltaY(); a mouse’s whole detents are in ticksY() (ADR-0056, ADR-0115). Natural scrolling and SDL’s away-from-the-user sign are both undone at the boundary, so a widget never sees either. --gb-scroll-line in the theme says how far one line moves a viewport.

Keys and text are different events

A KeyEvent is a key going down or up, with its Key, its Modifiers and whether it is a repeat. A TextEvent is committed text: what the platform’s compose and input method produced, which for anything but a Latin keyboard is not a key at all. One character can take several keystrokes (ADR-0055).

A widget that wants what the user typed implements onText, and says so with wantsTextInput(). That is what turns the platform’s text input on, and the input method with it; a board that only wants arrow keys leaves it off. A field that handles a key consumes it, and a key it does not handle goes on, so Tab still moves focus.

Accelerators

host.shortcut(Mod.CTRL.and(Key.T), actions::toggleTheme);   // enums: cannot be misspelled
host.shortcut("Primary+S", this::save);                     // the desktop's own modifier
host.removeShortcut("Primary+S");

An accelerator is per window and fires after the focused widget has declined the key, so a text field keeps its own Ctrl+A. The modifiers must match exactly: Ctrl+S does not fire on Ctrl+Shift+S. A string that names no key throws at the call.

Primary is the desktop’s own modifier: Cmd on macOS and Ctrl everywhere else. Shortcut.primary(Key.S) builds it in Java, and Mod, CmdOrCtrl and Primary all spell it in a string. Nothing else is translated: a shortcut that says Ctrl means the control key on every desktop, which is what a terminal or an Emacs-bound editor needs (ADR-0378). -Dgoldberry.input.primary=ctrl or =meta overrides the answer, and the toolkit’s own tests run with ctrl on every runner.

A widget that binds keys while mounted, a menubar, passes itself as the owner and gives them back with removeShortcut(shortcut, owner), so an unmount cannot take a binding somebody else made.

Focus

One focus owner per window. Focus moves by a press and by Tab and Shift+Tab in document order, and host.focus(id, fromKeyboard) moves it from Java: a dialog putting the caret in its first field, a form jumping to its first error. It is refused when the node cannot take focus, is disabled, or is outside a modal that is open.

:focus is true however focus arrived. :focus-visible is true only for keyboard focus, and the ring is drawn on that one, so a button clicked with a mouse gets no ring. Handles.onFocusChanged(focused, fromKeyboard) tells a widget the same thing, and onFocusWithin tells a container when focus enters or leaves its subtree as a whole.

A composite is one Tab stop

@Override public FocusScope focusScope() {
    return FocusScope.VERTICAL;     // Up and Down rove; Left and Right are mine
}

A radio group, a tab list, a menu or a toolbar is one Tab stop, with the arrow keys moving focus inside it (ADR-0073). The group says which arrows rove: HORIZONTAL, VERTICAL, or BOTH for a group whose direction is the stylesheet’s, which is what radio-group answers. Home and End reach the ends of any scope. Traversal enters at the descendant matching :checked, or the first one, so the selection is the roving position and there is no second piece of state to disagree with it.

The cursor

button    { cursor: pointer }
.splitter { cursor: ew-resize }
host.window().cursor(Cursor.WAIT);      // or decide it yourself

The cursor is a property of the painted rectangle, set from CSS or from code, and it inherits down the stack of rectangles under the pointer rather than the element tree. cursor: pointer on a button therefore covers the label inside it, and the shape freezes during a drag (ADR-0057). Custom image cursors are not built, and grab and grabbing fall back to move.

Dropped files and text

host.window().onFileDrop(drop -> {
    for (var path : drop.paths()) { board.add(path, drop.at()); }
});
host.window().onTextDrop(drop -> note.append(drop.lines()));

A desktop reports a drop as a beginning, a position, one event per file and an end. The toolkit reassembles that into one FileDrop carrying every path and the point it landed on, in the window’s logical coordinates, because a board needs to know where (ADR-0330). Both listeners return a Subscription to close when a widget that listened leaves the tree.

Context menus

panel id="company" context-menu="rows" {
  text "Frodo Baggins"
  text "Samwise Gamgee"
}
Menus.contextMenus(host, Map.of("rows", rowMenu()));

context-menu="rows" on any widget names a menu. A right-click, the Menu key or Shift+F10 on the focused widget finds the name by walking up from what is under the pointer, and hands it to the one handler the application registered with the point it happened at. What the name means is the catalogue’s, which is why Menus.contextMenus is the line that turns a name into a menu (ADR-0208). Bare F10 is the menu bar’s.

What a custom widget implements

A widget takes part in input by implementing Handles. Everything on it has a default, so a widget overrides what it uses.

MethodFor
onPointerCapture, onPointerthe pointer, root-first and then deepest-first
onKeyCapture, onKeykeys, the same two phases
onTextCapture, onText, onPreeditcommitted text, and an input method’s composition
isFocusable()whether Tab stops here. False by default
focusScope()whether the descendants are one Tab stop, and which arrows rove
onFocusChanged, onFocusWithinfocus arriving at this node, or anywhere in its subtree
wantsTextInput()whether the platform’s text input is on while this has focus
localPart()the part local() is measured against, as a CSS type name
gestureAnchor()a number to remember for the length of a drag
caretArea(), caretOffsetIn(area)where the caret is, for the input method’s candidate window

A canvas takes the same facts through its own Input, with onPointer, onKey, onText, onPreedit and wantsText(), and it is focusable exactly when it has one. The whole contract is in Writing a widget.

Read more