khora_editor/input.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8
9//! Editor input dispatch — keyboard shortcuts + camera navigation.
10//!
11//! Pulls per-frame `InputEvent`s from `EditorApp::update` and routes them
12//! to the editor camera, gizmo mode switches, the command palette and the
13//! workspace shortcuts. Viewport-rect aware so dragging across panels does not
14//! nudge the camera.
15
16use std::sync::{Arc, Mutex};
17
18use khora_sdk::editor_ui::{pick_handle, GizmoDrag, GizmoTransform};
19use khora_sdk::khora_core::math::Ray;
20use khora_sdk::prelude::ecs::EntityId;
21use khora_sdk::prelude::*;
22use khora_sdk::KeyCode;
23use khora_sdk::{EditorCamera, EditorMode, EditorState, GizmoMode, PlayMode};
24
25use crate::{mod_gizmo, ops};
26
27/// State of modifier keys + button drags that has to outlive a single
28/// frame. Lives on `EditorApp` and is mutated through this module.
29#[derive(Default)]
30pub struct InputState {
31 pub middle_down: bool,
32 pub right_down: bool,
33 pub shift_held: bool,
34 pub ctrl_held: bool,
35 pub prev_cursor: Option<(f32, f32)>,
36 /// Last known cursor position in physical screen pixels — kept in
37 /// sync with every `WindowEvent::CursorMoved`. Used by
38 /// `intercept_window_event` to test whether a `MouseInput` event
39 /// (which carries no position) lands inside the 3D viewport rect.
40 pub last_cursor_pos: Option<(f32, f32)>,
41 /// The gizmo handle currently being dragged, if any.
42 pub gizmo_drag: Option<GizmoDrag>,
43 /// Transforms the selection had when that drag began. A drag resolves
44 /// against these rather than against the live values, so it cannot
45 /// accumulate drift over a long gesture.
46 pub gizmo_starts: Vec<(EntityId, GizmoTransform)>,
47}
48
49impl InputState {
50 /// Drops every held button and modifier.
51 ///
52 /// Called when the window loses focus: a release that happens while another
53 /// application is in front never reaches us, so without this the flag stays
54 /// set forever — Alt-Tab with the middle button down and the camera orbits
55 /// on plain mouse movement from then on. A stuck `ctrl_held` is quieter and
56 /// worse: it disables the gizmo shortcuts, which just stop working.
57 pub fn release_all(&mut self) {
58 self.middle_down = false;
59 self.right_down = false;
60 self.shift_held = false;
61 self.ctrl_held = false;
62 self.prev_cursor = None;
63 self.end_gizmo_drag();
64 }
65
66 /// Drops any manipulation in progress, leaving the entities where the last
67 /// resolved delta put them.
68 fn end_gizmo_drag(&mut self) {
69 self.gizmo_drag = None;
70 self.gizmo_starts.clear();
71 }
72}
73
74/// The cursor ray for a screen position, or `None` when the viewport has not
75/// been laid out yet.
76fn cursor_ray(
77 camera: &Arc<Mutex<EditorCamera>>,
78 cursor: Option<(f32, f32)>,
79 viewport: Option<[f32; 4]>,
80) -> Option<Ray> {
81 let (cx, cy) = cursor?;
82 let [rx, ry, rw, rh] = viewport?;
83 let camera = camera.lock().ok()?;
84 Some(camera.screen_to_ray(cx - rx, cy - ry, rw, rh))
85}
86
87/// Drive the editor camera + global shortcuts off the per-frame input
88/// queue produced by the engine. World access is needed for `Delete`.
89pub fn process_events(
90 state: &mut InputState,
91 inputs: &[InputEvent],
92 world: &mut khora_sdk::GameWorld,
93 editor_state: &Arc<Mutex<EditorState>>,
94 camera: &Arc<Mutex<EditorCamera>>,
95) {
96 let (viewport_rect, play_mode) = editor_state
97 .lock()
98 .ok()
99 .map(|s| (s.viewport_screen_rect, s.play_mode))
100 .unwrap_or((None, PlayMode::Editing));
101 let cursor_in_viewport = |x: f32, y: f32| {
102 viewport_rect
103 .map(|[rx, ry, rw, rh]| x >= rx && x < rx + rw && y >= ry && y < ry + rh)
104 .unwrap_or(false)
105 };
106 // The editor camera is only navigable in Editing mode. In Play /
107 // Paused, mouse motion over the viewport must NOT move the editor
108 // camera — otherwise users see no visible difference between the
109 // two modes (and the active scene camera is the one that should
110 // render).
111 let editor_cam_navigable = play_mode == PlayMode::Editing;
112
113 for input in inputs {
114 match input {
115 InputEvent::MouseButtonPressed { button } => {
116 // Only arm camera navigation when the press *starts* in the
117 // viewport. Pressing on the inspector and dragging across used
118 // to grab the camera halfway.
119 let started_in_viewport = state
120 .last_cursor_pos
121 .map(|(x, y)| cursor_in_viewport(x, y))
122 .unwrap_or(false);
123 if started_in_viewport {
124 match button {
125 MouseButton::Middle => state.middle_down = true,
126 MouseButton::Right => state.right_down = true,
127 // Left click grabs a gizmo handle, or picks. Selection
128 // is no longer hierarchy-only: `GizmoMode::Select` and
129 // `screen_to_ray` both existed, but nothing cast a ray
130 // against the scene, so the viewport was read-only.
131 MouseButton::Left => {
132 let ray = cursor_ray(camera, state.last_cursor_pos, viewport_rect);
133 let view = viewport_rect.and_then(|[_, _, rw, rh]| {
134 camera.lock().ok().map(|cam| cam.view_info(rw, rh))
135 });
136
137 if let (Some(ray), Some(view)) = (ray, view) {
138 // A handle under the cursor wins over whatever
139 // is behind it: the manipulator sits on top of
140 // its own object, so picking first would make
141 // it impossible to grab.
142 let grabbed = editor_state.lock().ok().and_then(|s| {
143 let frame = mod_gizmo::selection_frame(world, &s, &view)?;
144 let axis = pick_handle(
145 s.gizmo_mode,
146 frame.pivot,
147 &frame.basis,
148 frame.size,
149 &ray,
150 )?;
151 let drag = GizmoDrag::begin(
152 s.gizmo_mode,
153 axis,
154 frame.pivot,
155 &frame.basis,
156 frame.size,
157 &ray,
158 )?;
159 Some((drag, mod_gizmo::capture_starts(world, &s)))
160 });
161
162 if let Some((drag, starts)) = grabbed {
163 state.gizmo_drag = Some(drag);
164 state.gizmo_starts = starts;
165 } else if let Ok(mut s) = editor_state.lock() {
166 match crate::picking::pick_entity(world, &ray) {
167 // Ctrl extends the selection, the
168 // same modifier the hierarchy uses.
169 Some(entity) if state.ctrl_held => s.toggle_select(entity),
170 Some(entity) => s.select(entity),
171 // Clicking empty space deselects —
172 // otherwise there is no way to let
173 // go of a selection in the viewport.
174 None if !state.ctrl_held => {
175 s.clear_selection();
176 s.inspected = None;
177 }
178 None => {}
179 }
180 }
181 }
182 }
183 _ => {}
184 }
185 }
186 }
187 InputEvent::MouseButtonReleased { button } => match button {
188 MouseButton::Middle => {
189 state.middle_down = false;
190 state.prev_cursor = None;
191 }
192 MouseButton::Right => {
193 state.right_down = false;
194 state.prev_cursor = None;
195 }
196 MouseButton::Left => state.end_gizmo_drag(),
197 _ => {}
198 },
199 InputEvent::KeyPressed { key_code } => {
200 if matches!(key_code, KeyCode::ShiftLeft | KeyCode::ShiftRight) {
201 state.shift_held = true;
202 }
203 if matches!(key_code, KeyCode::ControlLeft | KeyCode::ControlRight) {
204 state.ctrl_held = true;
205 }
206
207 if !state.ctrl_held {
208 let tool = match key_code {
209 KeyCode::KeyQ => Some(GizmoMode::Select),
210 KeyCode::KeyW => Some(GizmoMode::Move),
211 KeyCode::KeyE => Some(GizmoMode::Rotate),
212 KeyCode::KeyR => Some(GizmoMode::Scale),
213 _ => None,
214 };
215 if let Some(tool) = tool {
216 // A drag belongs to the tool it started with — its
217 // grabbed parameter means nothing under another one.
218 state.end_gizmo_drag();
219 if let Ok(mut s) = editor_state.lock() {
220 s.gizmo_mode = tool;
221 }
222 }
223 }
224
225 if *key_code == KeyCode::Delete {
226 if let Ok(mut s) = editor_state.lock() {
227 ops::delete_selection(world, &mut s);
228 }
229 }
230
231 if *key_code == KeyCode::KeyK && state.ctrl_held {
232 if let Ok(mut s) = editor_state.lock() {
233 s.command_palette_open = !s.command_palette_open;
234 }
235 }
236
237 // Ctrl+S — the shortcut every editor has, and the one whose
238 // absence people discover by losing work.
239 if *key_code == KeyCode::KeyS && state.ctrl_held {
240 if let Ok(mut s) = editor_state.lock() {
241 s.pending_menu_action = Some("save".to_owned());
242 }
243 }
244
245 // Ctrl+1 / Ctrl+2 — workspace switching, as the design doc
246 // specifies. Plain digits stay free for future tool bindings.
247 if state.ctrl_held {
248 let mode = match key_code {
249 KeyCode::Digit1 => Some(EditorMode::Scene),
250 KeyCode::Digit2 => Some(EditorMode::ControlPlane),
251 _ => None,
252 };
253 if let Some(mode) = mode {
254 if let Ok(mut s) = editor_state.lock() {
255 s.active_mode = mode;
256 }
257 }
258 }
259
260 // Escape retreats one level. Today that means closing the
261 // palette, then clearing the selection — the design doc's
262 // "Esc always retreats" applied to what exists.
263 if *key_code == KeyCode::Escape {
264 if let Ok(mut s) = editor_state.lock() {
265 if s.command_palette_open {
266 s.command_palette_open = false;
267 } else if !s.selection.is_empty() {
268 s.clear_selection();
269 s.inspected = None;
270 }
271 }
272 }
273
274 // Ctrl+Z / Ctrl+Y are unbound on purpose: nothing pushes onto
275 // `CommandHistory`, so both were no-ops. A shortcut that
276 // silently does nothing is worse than an absent one — it
277 // teaches the user their edits are reversible when they are not.
278 }
279 InputEvent::KeyReleased { key_code } => {
280 if matches!(key_code, KeyCode::ShiftLeft | KeyCode::ShiftRight) {
281 state.shift_held = false;
282 }
283 if matches!(key_code, KeyCode::ControlLeft | KeyCode::ControlRight) {
284 state.ctrl_held = false;
285 }
286 }
287 InputEvent::MouseMoved { x, y } => {
288 // A manipulation in progress owns the pointer: the camera must
289 // not also move, or the object being dragged slides out from
290 // under the cursor.
291 if state.gizmo_drag.is_some() {
292 if let Some(ray) = cursor_ray(camera, Some((*x, *y)), viewport_rect) {
293 let delta = state.gizmo_drag.as_mut().and_then(|d| d.update(&ray));
294 if let Some(delta) = delta {
295 mod_gizmo::apply_delta(world, delta, &state.gizmo_starts);
296 }
297 }
298 state.prev_cursor = Some((*x, *y));
299 continue;
300 }
301
302 if editor_cam_navigable && cursor_in_viewport(*x, *y) {
303 if let Some((px, py)) = state.prev_cursor {
304 let dx = x - px;
305 let dy = y - py;
306
307 if let Ok(mut cam) = camera.lock() {
308 // DCC convention, shared by Blender, Unity, Unreal
309 // and Godot: middle orbits, shift+middle pans.
310 // Right-drag used to pan, which left the button
311 // doing something no other 3D tool does and wasted
312 // the one people reach for to look around.
313 if state.middle_down && state.shift_held {
314 cam.pan(dx, dy);
315 } else if state.middle_down || state.right_down {
316 cam.orbit(dx, dy);
317 }
318 }
319 }
320 }
321 state.prev_cursor = Some((*x, *y));
322 }
323 InputEvent::MouseWheelScrolled { delta_y, .. } => {
324 let in_view = state
325 .last_cursor_pos
326 .map(|(x, y)| cursor_in_viewport(x, y))
327 .unwrap_or(false);
328 if editor_cam_navigable && in_view {
329 if let Ok(mut cam) = camera.lock() {
330 cam.zoom(*delta_y);
331 }
332 }
333 }
334 }
335 }
336}