concinnity_core/render/input.rs
1//! Backend-agnostic input snapshot returned by RenderBackend::take_input.
2//! Each backend was previously carrying its own structurally-identical
3//! InputState; this single type replaces those duplicates.
4
5/// Accumulated input state since the last poll. Drained and reset every
6/// frame by GraphicsSystem and converted into a FrameInput component for
7/// Camera3DSystem to consume.
8#[derive(Default, Debug, Clone, Copy)]
9pub struct RenderInput {
10 /// Forward movement key held.
11 pub forward: bool,
12 /// Backward movement key held.
13 pub backward: bool,
14 /// Left strafe key held.
15 pub left: bool,
16 /// Right strafe key held.
17 pub right: bool,
18 /// Sprint key held.
19 pub sprint: bool,
20 /// True for exactly one frame per interact-key press.
21 pub interact: bool,
22 /// True for exactly one frame per jump-key press.
23 pub jump: bool,
24 /// True while the Control key is held. A UI modifier (a story fast-forwards
25 /// its dialogue while it is down); not gated by menu state, like `escape` and
26 /// `captured_key`. Wired on Metal; DirectX / Vulkan set it from their key
27 /// callbacks.
28 pub ctrl: bool,
29 /// True while the Option/Alt key is held. A UI modifier (the editor's orbit
30 /// drag); not gated by menu state, like `ctrl`. Wired on Metal; DirectX /
31 /// Vulkan set it from their key callbacks.
32 pub alt: bool,
33 /// True while the platform's command modifier is held: the Command key on
34 /// macOS, where it is the idiomatic modifier for an application shortcut.
35 /// Windows and Linux leave this false and keep Ctrl as their shortcut
36 /// modifier, because the Super key there belongs to the desktop shell.
37 pub cmd: bool,
38 /// Accumulated mouse delta since the last take_input() call.
39 pub mouse_dx: f32,
40 /// Accumulated vertical mouse delta since the last take_input().
41 pub mouse_dy: f32,
42 /// Accumulated vertical scroll-wheel delta since the last take_input().
43 /// Only delivered while the cursor is free.
44 pub scroll_delta: f32,
45 /// Absolute cursor position in window pixels (origin top-left).
46 /// Only meaningful when the cursor is not captured.
47 pub mouse_x: f32,
48 /// Absolute cursor y in window pixels, origin top-left.
49 pub mouse_y: f32,
50 /// True for exactly one frame when the left mouse button is pressed
51 /// while the cursor is not captured.
52 pub left_click: bool,
53 /// True while the left mouse button is held (cursor not captured). Persists
54 /// across frames until release so a UI drag can track the cursor.
55 pub left_button_down: bool,
56 /// True for exactly one frame when the right mouse button is pressed
57 /// while the cursor is not captured. Wired on Metal; DirectX / Vulkan set
58 /// it from their mouse callbacks.
59 pub right_click: bool,
60 /// True for exactly one frame when the HUD-toggle key is pressed (F1).
61 pub hud_toggle: bool,
62 /// True for exactly one frame when Escape is pressed while the cursor is
63 /// not captured. (In captured-cursor worlds Escape continues to release
64 /// the cursor, as before, and this pulse stays false.)
65 pub escape: bool,
66 /// The canonical key pressed this poll, for the settings-menu rebind
67 /// capture, or `None`. A one-frame pulse, surfaced regardless of menu /
68 /// capture state. Wired on Metal; DirectX / Vulkan set it from their key
69 /// callbacks.
70 pub captured_key: Option<crate::components::InputKey>,
71 /// The printable character produced by this poll's key press (with the OS's
72 /// shift / dead-key / layout handling applied), for text-input fields, or
73 /// `None`. A one-frame pulse like `captured_key`, ungated by menu / capture
74 /// state. Editing keys (Backspace / Delete / arrows) are not here: those
75 /// arrive via `captured_key`. Wired on Metal; DirectX / Vulkan set it from
76 /// their WM_CHAR / char callback when built on Windows / Linux.
77 pub typed_char: Option<char>,
78}
79
80/// One frame's sampled window input, taken beside the backend right after the
81/// draw (whose event pump produced it) and consumed by the input system. In
82/// serial execution it is deposited and consumed within the same tick; the
83/// pipelined driver ships it across the thread boundary instead.
84#[derive(Default, Debug, Clone, Copy)]
85pub struct InputPacket {
86 /// The raw sampled input state.
87 pub raw: RenderInput,
88 /// Whether the cursor has left the window.
89 pub cursor_outside_window: bool,
90 /// Logical window size, for UI hit-testing and overlay layout.
91 pub viewport: (f32, f32),
92 /// Window chrome overlapping the top of the render surface, in the same
93 /// logical units as `viewport` (see `RenderBackend::top_content_inset`).
94 pub top_inset: f32,
95}
96
97impl InputPacket {
98 /// Sample the backend's accumulated input, cursor containment, and
99 /// logical size into one packet. Called right after the draw so the
100 /// frame's event pump is reflected.
101 pub fn sample(backend: &mut dyn crate::render::backend::RenderBackend) -> Self {
102 Self {
103 raw: backend.take_input(),
104 cursor_outside_window: backend.cursor_outside_window(),
105 viewport: backend.logical_size(),
106 top_inset: backend.top_content_inset(),
107 }
108 }
109
110 /// Fold a newer packet onto this one without losing edges: one-frame
111 /// pulses OR together, deltas accumulate, positions and held states take
112 /// the newer value. Only needed when a consumer misses a frame (startup,
113 /// a stall); steady state is one packet per tick.
114 pub fn merge_from(&mut self, newer: InputPacket) {
115 let old = self.raw;
116 let mut raw = newer.raw;
117 raw.interact |= old.interact;
118 raw.jump |= old.jump;
119 raw.left_click |= old.left_click;
120 raw.right_click |= old.right_click;
121 raw.hud_toggle |= old.hud_toggle;
122 raw.escape |= old.escape;
123 raw.mouse_dx += old.mouse_dx;
124 raw.mouse_dy += old.mouse_dy;
125 raw.scroll_delta += old.scroll_delta;
126 raw.captured_key = raw.captured_key.or(old.captured_key);
127 raw.typed_char = raw.typed_char.or(old.typed_char);
128 self.raw = raw;
129 self.cursor_outside_window = newer.cursor_outside_window;
130 self.viewport = newer.viewport;
131 self.top_inset = newer.top_inset;
132 }
133}
134
135// Scroll units emitted per physical wheel notch on backends whose wheel events
136// arrive as discrete notches (DirectX WM_MOUSEWHEEL, GLFW Scroll). macOS reports
137// precise (often large) scroll deltas directly, so Metal feeds scrollingDeltaY
138// raw and does not use this. The shared UI multiplies scroll_delta by its own
139// WHEEL_SCROLL_SPEED (see ui.rs), so this is scroll-delta units per notch.
140// Consumed by DirectX (WM_MOUSEWHEEL) and Vulkan (GLFW Scroll); dead on a Metal
141// build, which feeds scrollingDeltaY raw.
142pub(crate) const WHEEL_NOTCH_SCROLL_UNITS: f32 = 20.0;
143
144/// Convert a signed wheel rotation in notches (positive = rotated away from the
145/// user, i.e. scroll up) into an additive scroll_delta increment. Negated so a
146/// positive scroll_delta scrolls a panel's content up, matching
147/// FrameInput.scroll_delta's convention (see ui.rs and metal/input.rs).
148pub fn wheel_notches_to_scroll_delta(notches: f32) -> f32 {
149 -notches * WHEEL_NOTCH_SCROLL_UNITS
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 // A missed consume must not lose edges: pulses OR, deltas accumulate,
157 // positions and held state take the newer value, and an earlier one-frame
158 // Option pulse survives a newer empty poll.
159 #[test]
160 fn packet_merge_keeps_pulses_and_accumulates_deltas() {
161 let mut pending = InputPacket {
162 raw: RenderInput {
163 jump: true,
164 left_click: true,
165 mouse_dx: 2.0,
166 scroll_delta: 1.0,
167 mouse_x: 10.0,
168 left_button_down: true,
169 typed_char: Some('a'),
170 ..Default::default()
171 },
172 cursor_outside_window: true,
173 viewport: (100.0, 100.0),
174 top_inset: 0.0,
175 };
176 pending.merge_from(InputPacket {
177 raw: RenderInput {
178 interact: true,
179 mouse_dx: 3.0,
180 scroll_delta: -0.5,
181 mouse_x: 42.0,
182 left_button_down: false,
183 ..Default::default()
184 },
185 cursor_outside_window: false,
186 viewport: (200.0, 150.0),
187 top_inset: 28.0,
188 });
189 assert!(pending.raw.jump, "the earlier pulse survives");
190 assert!(pending.raw.left_click);
191 assert!(pending.raw.interact, "the newer pulse is present");
192 assert_eq!(pending.raw.mouse_dx, 5.0, "deltas accumulate");
193 assert_eq!(pending.raw.scroll_delta, 0.5);
194 assert_eq!(pending.raw.mouse_x, 42.0, "position takes the newer value");
195 assert!(
196 !pending.raw.left_button_down,
197 "held state takes the newer value"
198 );
199 assert_eq!(
200 pending.raw.typed_char,
201 Some('a'),
202 "the typed pulse survives"
203 );
204 assert!(!pending.cursor_outside_window);
205 assert_eq!(pending.viewport, (200.0, 150.0));
206 assert_eq!(
207 pending.top_inset, 28.0,
208 "the window metrics take the newer value"
209 );
210 }
211
212 #[test]
213 fn wheel_notch_sign_and_scale() {
214 // Rotating the wheel away from the user (positive notches, "scroll up")
215 // yields a negative scroll_delta so a panel's content moves down,
216 // revealing the top.
217 assert!(wheel_notches_to_scroll_delta(1.0) < 0.0);
218 // Rotating toward the user ("scroll down") yields a positive
219 // scroll_delta so the content moves up, revealing lower rows.
220 assert!(wheel_notches_to_scroll_delta(-1.0) > 0.0);
221 // The increment scales linearly with the number of notches.
222 assert_eq!(
223 wheel_notches_to_scroll_delta(-2.0),
224 2.0 * wheel_notches_to_scroll_delta(-1.0)
225 );
226 }
227}