feat(core): unified input state (keyboard/mouse/scroll)

Add InputState (DRAFT Etapes 15, sous-volt 15.B): pressed/held/released
semantics via HashSet rotation, mouse position/delta/scroll accumulators.
Wire it into App (pub field), forward winit WindowEvents in the event
handler, and begin_frame/end_frame around AppHandler::update.

Handleable key/mouse/scroll logic extracted into private helpers so all
five unit tests avoid constructing winit KeyEvent (private fields).
This commit is contained in:
Jérôme Bousquié
2026-09-20 07:57:26 +02:00
parent 4da89c7178
commit b41f7e259e
3 changed files with 314 additions and 1 deletions
+13 -1
View File
@@ -23,7 +23,7 @@
//! once right after GPU initialization so users can register shaders/meshes/materials/entities. //! once right after GPU initialization so users can register shaders/meshes/materials/entities.
use crate::AppHandler; use crate::AppHandler;
use crate::core::{Context, Renderer}; use crate::core::{Context, InputState, Renderer};
use crate::scene::Scene; use crate::scene::Scene;
use crate::utils::WsgError; use crate::utils::WsgError;
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH}; use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
@@ -46,6 +46,10 @@ use winit::window::{Window, WindowAttributes};
pub struct App { pub struct App {
/// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`. /// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`.
pub scene: Scene, pub scene: Scene,
/// Unified input state (keyboard/mouse/scroll, DRAFT Étape 15). Fed by the winit window events
/// and rotated each frame by `begin_frame`/`end_frame` around `AppHandler::update`. Read it in
/// `update` via `app.input` (e.g. `app.input.key_held(KeyCode::KeyW)`).
pub input: InputState,
/// Window title, read by the runner when the window is created in `resumed`. /// Window title, read by the runner when the window is created in `resumed`.
pub(crate) title: String, pub(crate) title: String,
/// Window width, read by the runner when the window is created in `resumed`. /// Window width, read by the runner when the window is created in `resumed`.
@@ -203,6 +207,7 @@ impl AppBuilder {
let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?; let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?;
Ok(App { Ok(App {
scene: Scene::new(), scene: Scene::new(),
input: InputState::new(),
title: self.title, title: self.title,
width: self.width, width: self.width,
height: self.height, height: self.height,
@@ -265,6 +270,7 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
let mut app = App { let mut app = App {
scene, scene,
input: InputState::new(),
title: self.title.clone(), title: self.title.clone(),
width: self.width, width: self.width,
height: self.height, height: self.height,
@@ -284,7 +290,11 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
let Some(app) = self.app.as_mut() else { let Some(app) = self.app.as_mut() else {
return; return;
}; };
// Étape 15 (input) : débute la frame d'input (rotation pressed/released + reset deltas),
// exécute la logique utilisateur, puis clôt (nettoie les états transitoires).
app.input.begin_frame();
self.handler.update(app); self.handler.update(app);
app.input.end_frame();
app.window().request_redraw(); app.window().request_redraw();
} }
@@ -299,6 +309,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
let Some(app) = self.app.as_mut() else { let Some(app) = self.app.as_mut() else {
return; return;
}; };
// Étape 15 (input) : alimente l'état unifié depuis les événements winit (clavier/souris/molette).
app.input.handle_window_event(&event);
match event { match event {
WindowEvent::Resized(size) => { WindowEvent::Resized(size) => {
// Garde (D3) : minimiser la fenêtre envoie Resized(0x0) ; ne jamais reconfigurer à 0. // Garde (D3) : minimiser la fenêtre envoie Resized(0x0) ; ne jamais reconfigurer à 0.
+299
View File
@@ -0,0 +1,299 @@
//! # Input Module — Unified Input State (Étape 15, ROADMAP 2.3)
//!
//! State d'entrée **unifié** (clavier / souris / molette) à la sémantique cross-frame
//! **pressed / held / released**, alimenté par les événements **winit** (`WindowEvent`), côté **CPU
//! (Rust)** — WGSL (langage de shader, GPU) n'a pas d'I/O. Ce module est incarné dans `App::input`
//! et piloté par la boucle : `begin_frame()` avant `AppHandler::update`, `end_frame()` après.
//!
//! ## Conventions
//! - **Clavier** : identifié par `KeyCode` (physique, indépendant de la disposition AZERTY/QWERTY :
//! la touche Z sur AZERTY est `KeyCode::KeyW`). `pressed`/`released` valent une seule frame,
//! `held` reste vrai tant que la touche est enfoncée.
//! - **Souris** : position absolue (pixels), delta par frame (dérivé des `CursorMoved`, donc des
//! déplacements relatifs valables pour une caméra orbitale en glisser), boutons
//! `pressed`/`held`/`released`, molette (`scroll`), `y > 0` = molette vers le haut.
//! - **Gamepad** : réservé pour une future v1 minimale (DRAFT D7) ; l'API est prête à accueillir
//! un `GamepadState` sans casser l'existant (l'exemple final n'a besoin que du clavier + souris).
//!
//! ## Exemples de requête (dans `AppHandler::update`)
//! ```
//! # use winit::keyboard::{KeyCode, PhysicalKey};
//! # fn demo(input: &wsg_lib::core::input::InputState) {
//! if input.key_held(KeyCode::KeyW) { /* avancer */ }
//! if input.key_pressed(KeyCode::Space) { /* sauter */ }
//! let (dx, dy) = input.mouse_delta();
//! if input.mouse_button_held(winit::event::MouseButton::Left) { /* orbiter */ }
//! let (_, zoom) = input.scroll_delta();
//! # }
//! ```
use std::collections::HashSet;
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
use winit::keyboard::{KeyCode, PhysicalKey};
/// État de saisie unifié, agrégat des groupes clavier, souris et molette. Il est **remis à jour à
/// chaque frame** par `App` via `begin_frame`/`end_frame`, et lu par l'utilisateur dans
/// `AppHandler::update` via `app.input`.
#[derive(Debug, Default, Clone)]
pub struct InputState {
// ---- Clavier ----
/// Touches physiquement enfoncées au moment présent (persiste entre les frames).
held: HashSet<KeyCode>,
/// Touches enfoncées pendant la frame courante (valides une seule frame).
pressed: HashSet<KeyCode>,
/// Touches relâchées pendant la frame courante (valides une seule frame).
released: HashSet<KeyCode>,
/// Accumulateur de `pressed` entre deux `begin_frame` (consommé à la rotation).
frame_pressed: HashSet<KeyCode>,
/// Accumulateur de `released` entre deux `begin_frame`.
frame_released: HashSet<KeyCode>,
// ---- Souris ----
/// Position absolue du curseur en pixels (dernière reçue).
mouse_position: (f32, f32),
/// Position absolue précédente, pour dériver le delta de `CursorMoved`.
last_mouse_position: Option<(f32, f32)>,
/// Déplacement relatif cumulé pendant la frame courante.
mouse_delta: (f32, f32),
/// Boutons enfoncés au moment présent.
held_buttons: HashSet<MouseButton>,
/// Boutons pressés pendant la frame courante.
pressed_buttons: HashSet<MouseButton>,
/// Boutons relâchés pendant la frame courante.
released_buttons: HashSet<MouseButton>,
/// Accumulateurs des boutons entre deux `begin_frame`.
frame_pressed_buttons: HashSet<MouseButton>,
frame_released_buttons: HashSet<MouseButton>,
// ---- Molette ----
/// Défilement cumulé pendant la frame courante (x, y).
scroll: (f32, f32),
// ---- Gamepad (réservé) ----
// (DRAFT D7 : v1 minimale optionnelle, reportée — l'API s'étendra sans rupture.)
}
impl InputState {
/// Crée un `InputState` vierge (toutes états vides). Équivalent à `Default`.
pub fn new() -> Self {
Self::default()
}
/// Consomme un événement de fenêtre winit et met à jour l'état interne (accumulateurs). Les
/// événements non pertinents sont ignorés. La rotation vers les ensembles requêtables
/// (`pressed`/`released`) se fait au prochain `begin_frame`.
pub fn handle_window_event(&mut self, event: &WindowEvent) {
match event {
WindowEvent::KeyboardInput { event: ke, .. } => {
let PhysicalKey::Code(code) = ke.physical_key else {
return; // touches non-figurées (ex. clavier système) ignorées
};
self.key_input(code, ke.state);
}
WindowEvent::MouseInput { state, button, .. } => self.mouse_button(*button, *state),
WindowEvent::CursorMoved { position, .. } => {
self.cursor_move(position.x as f32, position.y as f32);
}
WindowEvent::MouseWheel { delta, .. } => match delta {
MouseScrollDelta::LineDelta(x, y) => self.wheel(*x, *y),
MouseScrollDelta::PixelDelta(p) => self.wheel(p.x as f32, p.y as f32),
},
_ => {}
}
}
/// Enregistre un événement clavier brut (touche physique + état), appelé par
/// [`InputState::handle_window_event`]. Séparé pour être testable sans construire un `KeyEvent`.
fn key_input(&mut self, code: KeyCode, state: ElementState) {
match state {
ElementState::Pressed => {
self.held.insert(code);
self.frame_pressed.insert(code);
}
ElementState::Released => {
self.held.remove(&code);
self.frame_released.insert(code);
}
}
}
/// Enregistre un événement bouton de souris brut, appelé par [`InputState::handle_window_event`].
fn mouse_button(&mut self, button: MouseButton, state: ElementState) {
match state {
ElementState::Pressed => {
self.held_buttons.insert(button);
self.frame_pressed_buttons.insert(button);
}
ElementState::Released => {
self.held_buttons.remove(&button);
self.frame_released_buttons.insert(button);
}
}
}
/// Met à jour la position du curseur et cumule le déplacement relatif. Appelé par
/// [`InputState::handle_window_event`].
fn cursor_move(&mut self, x: f32, y: f32) {
if let Some((px, py)) = self.last_mouse_position {
self.mouse_delta.0 += x - px;
self.mouse_delta.1 += y - py;
}
self.last_mouse_position = Some((x, y));
self.mouse_position = (x, y);
}
/// Cumule le défilement de la molette. Appelé par [`InputState::handle_window_event`].
fn wheel(&mut self, dx: f32, dy: f32) {
self.scroll.0 += dx;
self.scroll.1 += dy;
}
/// Démarre une nouvelle frame pour l'input : **fait tourner** les accumulateurs d'événements
/// (accumulés entre deux `begin_frame`) vers les ensembles requêtables `pressed`/`released`, et
/// remet à zéro le delta de souris et la molette. À appeler **avant** `AppHandler::update`.
pub fn begin_frame(&mut self) {
self.pressed = std::mem::take(&mut self.frame_pressed);
self.released = std::mem::take(&mut self.frame_released);
self.pressed_buttons = std::mem::take(&mut self.frame_pressed_buttons);
self.released_buttons = std::mem::take(&mut self.frame_released_buttons);
self.mouse_delta = (0.0, 0.0);
self.scroll = (0.0, 0.0);
}
/// Clôt une frame : vide les ensembles transitoires `pressed`/`released` (déjà consommés par
/// `update`). Les états `held` et la position sont conservés. À appeler **après**
/// `AppHandler::update` (ou `render`).
pub fn end_frame(&mut self) {
self.pressed.clear();
self.released.clear();
self.pressed_buttons.clear();
self.released_buttons.clear();
}
// ---- Requêtes clavier ----
/// Vrai si `code` a été **enfoncée** pendant la frame courante (une seule frame).
pub fn key_pressed(&self, code: KeyCode) -> bool {
self.pressed.contains(&code)
}
/// Vrai si `code` est **maintenue** enfoncée (persiste entre les frames).
pub fn key_held(&self, code: KeyCode) -> bool {
self.held.contains(&code)
}
/// Vrai si `code` a été **relâchée** pendant la frame courante (une seule frame).
pub fn key_released(&self, code: KeyCode) -> bool {
self.released.contains(&code)
}
// ---- Requêtes souris ----
/// Position absolue du curseur en pixels (dernière position reçue).
pub fn mouse_position(&self) -> (f32, f32) {
self.mouse_position
}
/// Déplacement relatif de la souris cumulé pendant la frame courante.
pub fn mouse_delta(&self) -> (f32, f32) {
self.mouse_delta
}
/// Défilement de la molette cumulé pendant la frame courante (`(dx, dy)`, `dy > 0` = vers le haut).
pub fn scroll_delta(&self) -> (f32, f32) {
self.scroll
}
/// Vrai si `button` a été **pressé** pendant la frame courante (une seule frame).
pub fn mouse_button_pressed(&self, button: MouseButton) -> bool {
self.pressed_buttons.contains(&button)
}
/// Vrai si `button` est **maintenu** enfoncé (persiste entre les frames).
pub fn mouse_button_held(&self, button: MouseButton) -> bool {
self.held_buttons.contains(&button)
}
/// Vrai si `button` a été **relâché** pendant la frame courante (une seule frame).
pub fn mouse_button_released(&self, button: MouseButton) -> bool {
self.released_buttons.contains(&button)
}
}
#[cfg(test)]
mod tests {
use super::*;
use winit::event::MouseButton;
#[test]
fn keyboard_pressed_held_released_lifecycle() {
let mut input = InputState::new();
input.key_input(KeyCode::KeyW, ElementState::Pressed);
input.begin_frame();
assert!(input.key_pressed(KeyCode::KeyW));
assert!(input.key_held(KeyCode::KeyW));
assert!(!input.key_released(KeyCode::KeyW));
input.end_frame();
// Frame suivante sans nouvel événement : plus "pressed", toujours "held".
input.begin_frame();
assert!(!input.key_pressed(KeyCode::KeyW));
assert!(input.key_held(KeyCode::KeyW));
input.end_frame();
// Relâchement.
input.key_input(KeyCode::KeyW, ElementState::Released);
input.begin_frame();
assert!(input.key_released(KeyCode::KeyW));
assert!(!input.key_held(KeyCode::KeyW));
}
#[test]
fn mouse_buttons_lifecycle() {
let mut input = InputState::new();
input.mouse_button(MouseButton::Left, ElementState::Pressed);
input.begin_frame();
assert!(input.mouse_button_pressed(MouseButton::Left));
assert!(input.mouse_button_held(MouseButton::Left));
input.end_frame();
input.begin_frame();
assert!(!input.mouse_button_pressed(MouseButton::Left));
assert!(input.mouse_button_held(MouseButton::Left));
input.mouse_button(MouseButton::Left, ElementState::Released);
input.end_frame();
input.begin_frame();
assert!(input.mouse_button_released(MouseButton::Left));
assert!(!input.mouse_button_held(MouseButton::Left));
}
#[test]
fn mouse_delta_and_position_accumulate() {
let mut input = InputState::new();
input.begin_frame();
input.cursor_move(10.0, 20.0);
input.cursor_move(30.0, 40.0);
assert_eq!(input.mouse_delta(), (20.0, 20.0));
assert_eq!(input.mouse_position(), (30.0, 40.0));
input.end_frame();
// Nouvelle frame : le delta est remis à zéro au début, la position reste.
input.begin_frame();
assert_eq!(input.mouse_delta(), (0.0, 0.0));
assert_eq!(input.mouse_position(), (30.0, 40.0));
input.cursor_move(31.0, 42.0);
assert_eq!(input.mouse_delta(), (1.0, 2.0));
}
#[test]
fn scroll_accumulates_per_frame() {
let mut input = InputState::new();
input.begin_frame();
input.wheel(1.0, 2.0);
input.wheel(0.5, -1.0);
assert_eq!(input.scroll_delta(), (1.5, 1.0));
input.end_frame();
input.begin_frame();
assert_eq!(input.scroll_delta(), (0.0, 0.0));
}
#[test]
fn default_is_empty() {
let input = InputState::default();
assert!(!input.key_held(KeyCode::KeyW));
assert!(!input.mouse_button_held(MouseButton::Left));
assert_eq!(input.mouse_delta(), (0.0, 0.0));
assert_eq!(input.scroll_delta(), (0.0, 0.0));
}
}
+2
View File
@@ -11,9 +11,11 @@
pub mod context; pub mod context;
pub mod frame; pub mod frame;
pub mod input;
pub mod renderer; pub mod renderer;
// Re-exports // Re-exports
pub use context::Context; pub use context::Context;
pub use frame::Frame; pub use frame::Frame;
pub use input::InputState;
pub use renderer::Renderer; pub use renderer::Renderer;