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

4.6 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).
  • 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) et les autres touches deviennent des no-ops via les gardes de l'engine dès le premier événement traité.

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