4.2 KiB
Design : Répondre au clavier sur la version Desktop
Date : 2026-08-31 Statut : Validé par le produit (approche A choisie)
1. Objectif
Sur la version Desktop, on doit pouvoir répondre aux questions avec le clavier physique : les chiffres (rangée du haut et pavé numérique) saisissent la réponse, Entrée valide, Backspace efface. L'expérience tactile (pavé à l'écran) reste inchangée sur Android et iOS.
2. Décision
| Décision | Choix |
|---|---|
| Emplacement | UI partagée (GameScreen), pas de code spécifique desktop |
| Mécanisme | Modifier.onPreviewKeyEvent sur la racine de PlayingContent |
| Focus | FocusRequester + focusable() sur la racine, demandé à l'apparition |
| Touches | 0-9 (rangée haut + pavé), Entrée + Entrée pavé, Backspace |
| Consommation | Événements gérés consommés (true) |
Rejeté : interception au niveau de la fenêtre dans Main.kt (plomberie de
transformation d'événements Main → App → navigation → GameScreen, uniquement
pour desktop) ; champ texte invisible (hacky : curseur, IME, collage).
L'UI partagée convient car les événements clavier n'existent que sur machine avec clavier physique : sur mobile tactile, aucun événement n'arrive, aucune gating par plateforme n'est nécessaire.
3. Comportement
- Le mapping touche → action est pur et testable, indépendant du
KeyEvent(qu'on ne peut pas construire en test) :
sealed interface KeypadAction {
data class Digit(val char: Char) : KeypadAction
object Delete : KeypadAction
object Validate : KeypadAction
}
object GameKeyMapping {
fun actionFor(key: Key, type: KeyEventType): KeypadAction?
}
- Mapping (uniquement sur
KeyDown,nullsinon → pas d'action) :Key.Zero–Key.NineetKey.NumPad0–Key.NumPad9→DigitKey.EnteretKey.NumPadEnter→ValidateKey.Backspace→Delete- toute autre touche →
null
- Le handler
onPreviewKeyEventdePlayingContentappelleengine.onDigit / onDelete / onValidateselon l'action et consomme l'événement, pour qu'Entrée n'active pas aussi un bouton du pavé qui aurait le focus (le pavé passe par la phase preview avant les boutons). Key.Zero–Key.Ninesont des touches positionnelles : sur AZERTY, les touches non modifiées (&,é,", …) saisissent aussi des chiffres — sans besoin de Shift, adapté au public enfant.
4. Cas limites
| Cas | Comportement |
|---|---|
| Feedback (1,5 s) | PlayingContent non composé → touches ignorées |
| Question suivante | Re-composition → requestFocus() relancé |
| Entrée à vide / Backspace à vide | Ignorés par l'engine (événement consommé quand même) |
| Dialog « Quitter la partie ? » | Fenêtre séparée sur desktop, les touches vont au dialog |
| Limite de saisie | MAX_ENTRY_LENGTH (4) déjà géré dans GameEngine |
| Bouton du pavé cliqué à la souris | Il hérite du focus, mais la phase preview de la racine voit d'abord les touches |
5. Implémentation
sharedUI/src/commonMain/kotlin/com/livrets/ui/GameKeyMapping.kt:KeypadAction+GameKeyMapping.actionFor(key, type).GameScreen.kt: sur leColumnracine dePlayingContent, ajouter.focusRequester(fr).focusable().onPreviewKeyEvent { ... }et unLaunchedEffect { fr.requestFocus() }. LeKeypadexistant ne change pas.- Aucun changement :
GameEngine,Main.kt, écrans non concernés.
6. Tests (commonTest, GameKeyMappingTest)
- Chiffres rangée du haut (
Key.Zero…Key.Nine) →Digitcorrespondant. - Chiffres pavé numérique (
Key.NumPad0…Key.NumPad9) →Digitcorrespondant. Key.Enter→Validate.Key.NumPadEnter→Validate.Key.Backspace→Delete.KeyUp(ex.Key.OnesurKeyUp) →null.- Touches non gérées (
Key.A,Key.Escape,Key.Spacebar…) →null.
Le câblage (focus, consommation, re-focus après feedback) est validé
manuellement via ./gradlew :desktopApp:run.
7. Hors périmètre
- Échap pour ouvrir la boîte « Quitter la partie ? » (refusé par le produit)
- Raccourcis clavier sur l'accueil, les résultats et l'historique
- Tout changement sur Android et iOS (pas de gating, mais aucun comportement tactile modifié)