98 lines
4.6 KiB
Markdown
98 lines
4.6 KiB
Markdown
# 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é)
|