← All Workshops

MudEngine Part 5: Polished UI with dioxus-components

Step 10 / 13

Global keyboard shortcuts

Three navigation methods exist: type north in the input, click the D-pad buttons, or click a room card. Now we add a fourth: keyboard shortcuts that work when the input is not focused.

We track whether the input field is focused using an onfocus/onblur pair on the Input component. When it is not focused, onkeydown on the root #game div maps arrow keys, WASD, and vim keys to world.write().go(dir).

1. Add an input-focused signal

At the top of the App component, right after the existing feedback signal:

mud-engine/src/components/app.rs
let mut input = use_signal(|| String::new());
let mut feedback = use_signal(|| String::new());

// add this:
let mut input_focused = use_signal(|| false);
⌨️ Key bindings
KeysDirection
/ w / kNorth
/ s / jSouth
/ a / hWest
/ d / lEast
2. Update the Input with focus events

In App's rsx!, find the existing Input component and add onfocus and onblur props. The handler code inside onkeydown stays the same — only two new lines are added:

mud-engine/src/components/app.rs
Input {
    style: "width: 100%;",
    placeholder: "Type a command and press Enter...",
    value: input,
    oninput: move |e: FormEvent| input.set(e.value()),

    // add these:
    onfocus: move |_| { input_focused.set(true); },
    onblur: move |_| { input_focused.set(false); },

// ...
3. Auto-focus on mount with use_effect

A div needs focus to receive onkeydown events. We auto-focus #game after the page loads using a use_effect hook with document::eval — a Dioxus API that runs JavaScript in the browser. Add this right after the signals at the top of App:

mud-engine/src/components/app.rs
use_effect(move || {
    let _ = document::eval("document.getElementById('game')?.focus()");
});
4. Re-focus on input blur

When the user presses Enter, the input blurs and loses focus. Add a document::eval call to the existing onblur handler so #game gets focus back immediately — keyboard shortcuts will work without an extra click:

mud-engine/src/components/app.rs
onblur: move |_| {
    input_focused.set(false);
    let _ = document::eval("document.getElementById('game')?.focus()");
},
5. Add onkeydown to #game

Now add the onkeydown handler to the app container div { id: "game" } (the container created back in Part 4). Keep all existing children (Card, DirectionPad, feedback, Input) inside:

mud-engine/src/components/app.rs
div {
    id: "game",
    tabindex: "0", // required so the div can receive focus (see tip below)
    onkeydown: move |e: KeyboardEvent| {
        if input_focused() { return; }
        let dir = match e.key() {
            Key::ArrowUp => Some(Direction::North),
            Key::ArrowDown => Some(Direction::South),
            Key::ArrowLeft => Some(Direction::West),
            Key::ArrowRight => Some(Direction::East),
            Key::Character(c) => match c.as_str() {
                "w" | "k" => Some(Direction::North),
                "s" | "j" => Some(Direction::South),
                "a" | "h" => Some(Direction::West),
                "d" | "l" => Some(Direction::East),
                _ => None,
            },
            _ => None,
        };
        if let Some(d) = dir {
            e.prevent_default();
            if !world.write().go(d) {
                feedback.set(
                    format!("You cannot go {d} from here."),
                );
            }
        }
    },
    // ... existing children (Card, DirectionPad, feedback, Input) ...
}
⚠️ Two things had to be true for keyboard shortcuts to work

Keyboard movement is easy to get wrong because all of these must hold at once. They were the actual reasons it silently did nothing:

1. The focused element must be your div, not Dioxus's

Dioxus mounts your whole app inside its own <div id="main"> container. If your app container were also id="main", then document.getElementById('main') would return Dioxus's container — not yours. That is why the app container uses the unique id id="game" (set back in Part 4): a unique id so getElementById('game') finds your div.

2. A plain <div> is not focusable — add tabindex

Even with the right element, div.focus() is a silent no-op unless the element has a tabindex attribute (the same attribute that lets you Tab into a div). tabindex: "0" makes #game focusable by both click and focus().

3. The handler must not be on Dioxus's root element

Dioxus does not dispatch bubbling events to the outermost mounted element (it is reserved as the app container). A onkeydown placed on the very first element Dioxus mounts would never fire — which is another reason we put the handler on #game, a nested element, not on Dioxus's container.

💡 Why this is easy to miss: the code compiles and runs, use_effect fires, and document::eval runs the JavaScript — but focusing a non-focusable element does nothing, and the wrong #main can be focused without any error. If keyboard movement ever stops working, inspect document.activeElement and check that it is the element carrying your onkeydown before assuming the handler logic is wrong.

One caveat: clicking the D-pad buttons or room cards moves focus to that button/cell, so #game loses focus and the shortcuts pause until the input is used (its onblur re-focuses #game). If you want the shortcuts to survive mouse-driven movement, re-focus #game after those clicks too, e.g. document.getElementById('game')?.focus() at the end of the same onclick that moves the player.

🎯 How it works
  1. use_effect runs once after the component mounts. It calls document::eval which runs JavaScript document.getElementById('game')?.focus() — this focuses the #game div (made focusable by its tabindex) so keyboard events start working immediately.
  2. onblur on the Input — when you press Enter (or click away), the input loses focus. The onblur handler re-focuses #game so the shortcuts work again without needing to click anywhere.
  3. onkeydown on #game — the div is now the focused element, so every key press goes here first. It checks input_focused() and returns early if you are still typing. For movement keys, it calls e.prevent_default() (stops arrow keys from scrolling) then world.write().go(dir).
  4. Feedback — blocked directions display the same error message as typed commands.

No conflicts

While typing in the inputWhile #game is focused
Arrow keys move the cursorArrow keys move the player
WASD/HJKL are typed as charactersWASD/HJKL move the player
Enter submits the commandEnter does nothing (no handler)
Step 10 / 13