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

4.0 KiB
Raw Blame History

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, 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é)