MudEngine Part 5: Polished UI with dioxus-components
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:
let mut input = use_signal(|| String::new()); let mut feedback = use_signal(|| String::new()); // add this: let mut input_focused = use_signal(|| false);
| Keys | Direction |
|---|---|
↑ / w / k | North |
↓ / s / j | South |
← / a / h | West |
→ / d / l | East |
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:
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); }, // ...
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:
use_effect(move || { let _ = document::eval("document.getElementById('game')?.focus()"); });
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:
onblur: move |_| { input_focused.set(false); let _ = document::eval("document.getElementById('game')?.focus()"); },
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:
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) ... }
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_effectfires, anddocument::evalruns the JavaScript — but focusing a non-focusable element does nothing, and the wrong#maincan be focused without any error. If keyboard movement ever stops working, inspectdocument.activeElementand check that it is the element carrying youronkeydownbefore 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.
use_effectruns once after the component mounts. It callsdocument::evalwhich runs JavaScriptdocument.getElementById('game')?.focus()— this focuses the#gamediv (made focusable by itstabindex) so keyboard events start working immediately.onbluron the Input — when you press Enter (or click away), the input loses focus. Theonblurhandler re-focuses#gameso the shortcuts work again without needing to click anywhere.onkeydownon#game— the div is now the focused element, so every key press goes here first. It checksinput_focused()and returns early if you are still typing. For movement keys, it callse.prevent_default()(stops arrow keys from scrolling) thenworld.write().go(dir).- Feedback — blocked directions display the same error message as typed commands.
No conflicts
| While typing in the input | While #game is focused |
|---|---|
| Arrow keys move the cursor | Arrow keys move the player |
| WASD/HJKL are typed as characters | WASD/HJKL move the player |
| Enter submits the command | Enter does nothing (no handler) |