egui Keyboard Input: Arrow Keys, Clicks & Events | Rust GUI Ep 21
Video: egui Keyboard Input: Arrow Keys, Clicks & Events | Rust GUI Ep 21 by Taught by Celeste AI - AI Coding Coach
ctx.input(|i| i.key_pressed(egui::Key::ArrowUp))— read keyboard events for the current frame.
So far interaction has come from clicks and drags. Today we add the keyboard. egui exposes per-frame input through the context — for any frame, you can ask "was this key pressed this frame?", "is it currently held?", "was a character typed?", "what is the cursor position?" — and react accordingly.
We build a small "key explorer": a square moves around the canvas with arrow keys (or WASD), the Tab key cycles through colors, Space resets, and clicks on the canvas drop coloured markers. Every input gets logged in a sidebar.
What we are building
Sidebar on the left with instructions, the current state (position, color, marker count), a speed control, and a scrolling input log. Central canvas showing a coloured rectangle whose position is keyboard-driven, plus markers placed by clicks.
ctx.input
ctx.input(|i| {
if i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::W) {
self.y -= self.speed;
self.log.push("Move up".to_string());
}
// ... other keys
});
ctx.input(|i| { ... }) calls the closure with an InputState reference. The closure must be brief — it borrows internal state. Read what you need, return.
Useful methods on InputState:
.key_pressed(key)— true once on the frame the key transitioned from up to down. Edge-triggered..key_down(key)— true while the key is held. Level-triggered..key_released(key)— true once on release..modifiers— the current modifier state (ctrl,alt,shift,mac_cmd)..pointer.hover_pos()— current cursor position..pointer.primary_clicked()— left-click event..smooth_scroll_delta— scroll wheel motion..events— all events for this frame as an iterator.
For movement, key_pressed (one-shot per press) is right for step movement. key_down (continuous) is right when you want continuous motion as long as the key is held.
Multi-key support
if i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::W) {
self.y -= self.speed;
}
Arrow keys or WASD do the same thing. The keyboard is forgiving; both layouts feel natural for navigation. The pattern is OR-ing multiple keys for a single action.
For game-like apps, you might add diagonal motion: check ArrowUp + ArrowRight together and move at a normalised diagonal speed. The structure stays the same — read all the relevant keys, compute the resulting motion, apply.
Persistent animation
ctx.request_repaint();
Same pattern as Episode 15. Without it, idle frames don't repaint, and the keyboard input wouldn't be checked. Calling request_repaint() keeps the loop running so we always sample input.
In a real game you would scope this more tightly (only call when the player is moving), but for this demo, calling it every frame is fine.
Click handling on the canvas
let (canvas, response) = ui.allocate_exact_size(
size, egui::Sense::click(),
);
if response.clicked() {
if let Some(pos) = response.interact_pointer_pos() {
let local = egui::pos2(
pos.x - canvas.min.x,
pos.y - canvas.min.y,
);
self.markers.push((local, color));
}
}
Sense::click() enables click detection on the rectangle. response.clicked() is true on the frame of a complete click. interact_pointer_pos() returns the cursor position at the click — Some(pos) if there was a click, None if the response wasn't actually clicked.
We convert from screen coordinates to canvas-local coordinates by subtracting canvas.min — the canvas's top-left position. That way markers are stored relative to the canvas and survive repositioning.
The input log
if self.log.len() > 12 {
self.log.drain(0..self.log.len() - 12);
}
Cap the log at 12 entries. Whenever it grows past that, drain the oldest entries.
ui.label("Input Log:");
for entry in self.log.iter().rev() {
ui.small(entry);
}
Display entries newest-first. ui.small is like ui.label with a smaller font — appropriate for log lines that should not dominate.
State-driven rendering
let center = egui::pos2(
canvas.min.x + self.x,
canvas.min.y + self.y,
);
let rect = egui::Rect::from_center_size(center, egui::vec2(30.0, 30.0));
painter.rect_filled(rect, 4.0, color);
The rectangle's screen position is computed from self.x, self.y, and the canvas origin. The keyboard handler updates self.x and self.y; the painter reads them. State is the wire between input and output.
Running it
cargo run. Click the canvas to focus the window. Press an arrow key — the rectangle moves. Press Tab — the color cycles. Press Space — everything resets. Click anywhere on the canvas — a marker drops. Each action gets logged in the sidebar.
Adjust the Speed drag value — movement step changes. Pressing arrows now moves the rectangle by the new step.
Common mistakes
Using key_pressed inside ctx.input for continuous movement. key_pressed fires once per press. For hold-to-move behaviour, use key_down.
Forgetting ctx.request_repaint() for animated input. Without continuous repaint, key events between mouse moves are missed.
Reading input outside ctx.input(|i| ...). Cheaply but incorrect — InputState exists only inside the closure. Always do the read inside.
Not clamping position. A rectangle drifts off the canvas if you forget to clamp. Apply clamp after every movement.
Confusing screen and local coordinates. Click positions are screen-space; storing them as-is means they break when the canvas moves. Convert to local space at the moment of input, store local.
What's next
Next episode: floating windows. egui::Window::new("Title").open(&mut bool) — pop-up windows for editing posts, showing about-info, confirming destructive actions. The pattern that gives any egui app dialogs.
Recap
ctx.input(|i| { ... }) to read per-frame input. i.key_pressed(key) for edge events, i.key_down(key) for hold. Combine with Sense::click() and response.interact_pointer_pos() for canvas clicks. Use ctx.request_repaint() to keep the loop spinning while waiting for input.
Next episode: floating windows. See you in the next one.