Skip to main content

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}