diff --git a/docs/superpowers/specs/2026-08-31-clavier-desktop-design.md b/docs/superpowers/specs/2026-08-31-clavier-desktop-design.md new file mode 100644 index 0000000..1f16607 --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-clavier-desktop-design.md @@ -0,0 +1,95 @@ +# 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) : + +```kotlin +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`, `null` sinon → pas d'action) : + - `Key.Zero`–`Key.Nine` et `Key.NumPad0`–`Key.NumPad9` → `Digit` + - `Key.Enter` et `Key.NumPadEnter` → `Validate` + - `Key.Backspace` → `Delete` + - toute autre touche → `null` +- Le handler `onPreviewKeyEvent` de `PlayingContent` appelle + `engine.onDigit / onDelete / onValidate` selon 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). + +## 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 + +1. `sharedUI/src/commonMain/kotlin/com/livrets/ui/GameKeyMapping.kt` : + `KeypadAction` + `GameKeyMapping.actionFor(key, type)`. +2. `GameScreen.kt` : sur le `Column` racine de `PlayingContent`, ajouter + `.focusRequester(fr).focusable().onPreviewKeyEvent { ... }` et un + `LaunchedEffect { fr.requestFocus() }`. Le `Keypad` existant ne change pas. +3. Aucun changement : `GameEngine`, `Main.kt`, écrans non concernés. + +## 6. Tests (commonTest, `GameKeyMappingTest`) + +1. Chiffres rangée du haut (`Key.Zero`…`Key.Nine`) → `Digit` correspondant. +2. Chiffres pavé numérique (`Key.NumPad0`…`Key.NumPad9`) → `Digit` correspondant. +3. `Key.Enter` → `Validate`. +4. `Key.NumPadEnter` → `Validate`. +5. `Key.Backspace` → `Delete`. +6. `KeyUp` (ex. `Key.One` sur `KeyUp`) → `null`. +7. 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é)