Pantrix UI

Keyboard shortcuts

Why, in Compose Multiplatform, a keyboard shortcut that belongs to the whole window (such as Cmd+K or Ctrl+K) does not work with a single onPreviewKeyEvent, what the library gives for it, how a shortcut is written on each system, and how it is tested. The problem is not Pantrix's own: it shows the same way in every Compose Multiplatform project with an app shortcut in its common code.

Everything said here of Compose was read in the sources of Compose Multiplatform 1.12.1, or seen in a test that runs in this repository, on a device or in a browser; which was seen where is written in sections 7 and 8.

1. The problem

Symptom

Cause

A key event is given to the node that has the focus and to its ancestors. While nothing has the focus, Compose gives the event to the listeners above the root focus node: no listener in the content is one of them. FocusOwnerImpl.dispatchKeyEvent (Compose Multiplatform 1.12.1, commonMain/androidx/compose/ui/focus/FocusOwnerImpl.kt):

val activeFocusTarget = findFocusTargetNode()
val focusedKeyInputNode =
    activeFocusTarget?.lastLocalKeyInputNode()
        ?: activeFocusTarget?.nearestAncestorIncludingSelf(Nodes.KeyInput)?.node
        ?: rootFocusNode.nearestAncestor(Nodes.KeyInput)?.node

rootFocusNode.nearestAncestor(…) is the platform's own nodes; an onPreviewKeyEvent on the app's root is below them, and is skipped. For a shortcut to be heard at all times, the root itself has to have the focus while nothing else does.

A node that does this has to watch two things too:

2. The solution: three parts

PartWhat it does
PantrixShortcut(key, command, shift, alt)A key and its modifiers. command is Command on Apple's systems and Control on the others. isPressedBy(event, system): whether exactly these modifiers are held as the key goes down, and no other. keysOn(system), writtenOn(system): as the system writes it.
Modifier.onShortcut(shortcut) { … }While the focus is on this node or inside it, hears the shortcut before the control that has the focus does (onPreKeyEvent). It swallows the lifting of the key it ran too.
Modifier.shortcutRoot()Holds the focus while nothing inside it does; it is no stop for Tab.

LocalOperatingSystem (foundation/platform) says which system the shortcut is pressed on: its default is currentOperatingSystem(); a test gives another system.

3. With Pantrix

3.1. The whole window's shortcut

var palette by rememberSaveable { mutableStateOf(false) }
Box(Modifier.shortcutRoot().onShortcut(PantrixShortcut(Key.K, command = true)) { palette = true }) {
    App()
    PantrixCommandPalette(visible = palette, onDismissRequest = { palette = false }, …)
}

shortcutRoot stands once, at the window's root. The shortcuts have to be on the same node as it or above it: while the root has the focus, only the root's and its ancestors' listeners hear. Where two roots stand side by side for a moment, as two screens do while one gives way to the other, the one that has the focus keeps it.

3.2. The commands' own shortcuts

Modifier.commandShortcuts(commands, onRun) binds the shortcut of every command in the list that can be run. What the palette shows and what the keyboard runs is one list:

Box(
    Modifier
        .shortcutRoot()
        .onShortcut(PantrixShortcut(Key.K, command = true)) { palette = true }
        .commandShortcuts(commands, onRun = { run(it.key) })
) { … }

A command with enabled = false lets its key go by; of two commands that carry the same shortcut, the first runs. While a Pantrix modal is open the keys are its: the shortcuts below it are not heard.

3.3. How a shortcut is written

val shortcut = PantrixShortcut(Key.R, command = true, shift = true)
shortcut.writtenOn(OperatingSystem.MacOs)   // "⇧⌘R"
shortcut.writtenOn(OperatingSystem.Windows) // "Ctrl+Shift+R"
shortcut.keysOn(LocalOperatingSystem.current) // for keycaps, one by one

Apple's order is Option, Shift, Command and the key; the others' is Ctrl, Alt, Shift and the key. Letters, digits, the arrows, F1–F12, Enter, Escape, Tab, space, Backspace, Delete, Home and End, the page keys and the common signs have names; any other key is written #its number.

4. Without Pantrix

The root's node, ShortcutRootNode (ui/foundation/.../keyboard/Shortcuts.kt), is built without the library so:

fun Modifier.shortcutRoot(): Modifier = this then ShortcutRootElement

private object ShortcutRootElement : ModifierNodeElement<ShortcutRootNode>() {
    override fun create() = ShortcutRootNode()
    override fun update(node: ShortcutRootNode) = Unit
    override fun hashCode() = "shortcutRoot".hashCode()
    override fun equals(other: Any?) = other === this
}

private class ShortcutRootNode : DelegatingNode(), KeyInputModifierNode, CompositionLocalConsumerModifierNode {
    private val target = delegate(
        FocusTargetModifierNode(focusability = Focusability.Always, onFocusChange = { _, state -> changed(state) })
    )

    override fun onAttach() = takeIfNothingHasIt()

    override fun onPreKeyEvent(event: KeyEvent) = false

    // While the root has the focus, it takes Tab itself and carries it to what is inside.
    override fun onKeyEvent(event: KeyEvent): Boolean {
        if (event.type != KeyEventType.KeyDown || event.key != Key.Tab || !target.focusState.isFocused) return false
        val focus = currentValueOf(LocalFocusManager)
        focus.moveFocus(FocusDirection.Enter)
        if (event.isShiftPressed) focus.moveFocus(FocusDirection.Previous)
        return true
    }

    private fun changed(state: FocusState) {
        // While something inside has the focus the root is no stop for Tab; and since going round lets go of the
        // focus and looks for the first, the root takes back its focusability only once everything has settled.
        if (state.hasFocus && !state.isFocused) target.focusability = Focusability.Never
        if (!state.hasFocus) takeIfNothingHasIt()
    }

    private fun takeIfNothingHasIt() {
        coroutineScope.launch {
            if (!target.focusState.hasFocus) {
                target.focusability = Focusability.Always
                target.requestFocus()
            }
        }
    }
}

The matching rule is one line; for the system, navigator.platform (a browser) or os.name (the JVM) is read:

val lead = if (apple) event.isMetaPressed else event.isCtrlPressed
val stray = if (apple) event.isCtrlPressed else event.isMetaPressed
val pressed = event.type == KeyEventType.KeyDown && event.key == key && lead == command && !stray &&
    event.isShiftPressed == shift && event.isAltPressed == alt

5. What the helper does not do

6. Testing

setContent {
    CompositionLocalProvider(LocalOperatingSystem provides OperatingSystem.MacOs) {
        Box(Modifier.testTag("field").onShortcut(PantrixShortcut(Key.K, command = true)) { runs++ }.focusable())
    }
}
onNodeWithTag("field").requestFocus()
onNodeWithTag("field").performKeyInput { withKeyDown(Key.MetaLeft) { pressKey(Key.K) } }

7. Pitfalls

8. Verification

Tests

TestWhat it holdsTargets
foundation/.../keyboard/ShortcutsTestHow it is written (Apple's and the others', every named key), equality, being pressed with exactly its modifiers, the down and the lifting not given to what has the focus, the outer one winning, the root taking the focus at the start and when the focus is let go, Tab skipping the rootJVM, iOS simulator, JS, Wasm
components/.../CommandShortcutsTestEvery command's shortcut, the one that cannot be run letting its key go by, the first winningJVM, iOS, JS, Wasm
components/.../CommandPaletteTestThe shortcut in the palette's rows read as a state, as the system writes itJVM, iOS, JS, Wasm

Android's host tests do not see commonTest (see ROBORAZZI_GUIDE.md).

Looked at by hand

Not verified

9. Where it is in this repository

FileWhat is in it
ui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/keyboard/PantrixShortcut.ktPantrixShortcut, the keys' names
ui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/keyboard/Shortcuts.ktonShortcut, shortcutRoot
ui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/platform/OperatingSystem.ktOperatingSystem, LocalOperatingSystem
ui/components/src/commonMain/kotlin/com/pantrixui/components/overlay/commandpalette/CommandShortcuts.ktcommandShortcuts
app/shared/src/commonMain/kotlin/com/pantrixui/cmp/demo/shell/DemoCommands.ktThe demo's root and its own palette

10. What is left open

In docs/OPEN_ITEMS.md: shortcuts in sequence, the repeats of holding down, the iPad's list of shortcuts, and verifying with a hardware keyboard on iOS.