Files
livrets/docs/superpowers/specs/2026-08-31-clavier-desktop-design.md
T

98 lines
4.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
- `Key.Zero`–`Key.Nine` sont des touches **positionnelles** : sur AZERTY, les touches non modifiées (`&`, `é`, `"`, …) saisissent aussi des chiffres — sans besoin de Shift, adapté au public enfant.
- Maintien d'une touche : Compose 1.12 ne distingue pas l'auto-répétition (`KeyEventType` n'a que `Unknown`/`KeyDown`/`KeyUp`), donc un chiffre maintenu se répète. La saisie reste bornée à 4 caractères (`MAX_ENTRY_LENGTH`) ; Entrée ne valide qu'une seule fois (l'état passe en feedback), Backspace s'arrête à la saisie vide, et hors de l'état `Playing` tout est ignoré.
## 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é)