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

11 KiB
Raw Blame History

Clavier physique sur la version Desktop — Plan d'implémentation

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Sur la version Desktop, répondre aux questions avec le clavier physique : chiffres (rangée du haut et pavé numérique) saisissent, Entrée valide, Backspace efface.

Architecture: Deux unités. (1) GameKeyMapping, logique pure de mapping touche → action (KeypadAction), testable en commonTest sans UI. (2) Câblage dans GameScreen.PlayingContent : onPreviewKeyEvent sur le Column racine + FocusRequester/focusable() pour recevoir les événements ; l'handler appelle les callbacks existants du GameEngine et consomme l'événement. Aucun code spécifique desktop : sur mobile tactile aucun événement clavier n'arrive.

Tech Stack: Kotlin Multiplatform (commonMain/commonTest), Compose Multiplatform 1.12.0 (androidx.compose.ui.input.key, androidx.compose.ui.focus), kotlin.test, Gradle.

Spec: docs/superpowers/specs/2026-08-31-clavier-desktop-design.md

Global Constraints

  • Commande de test : ./gradlew :sharedUI:jvmTest (kotlin.test, tests dans commonTest)
  • Vérification manuelle : ./gradlew :desktopApp:run (fenêtre 480×800)
  • Constantes vérifiées dans CMP 1.12.0 : Key.Zero–Key.Nine, Key.NumPad0–Key.NumPad9, Key.Enter, Key.NumPadEnter, Key.Backspace, Key.A, Key.Escape, Key.Spacebar, Key.Tab, KeyEventType.KeyDown, KeyEventType.KeyUp ; event.key et event.type sont des propriétés d'extension (androidx.compose.ui.input.key.key / .type)
  • GameEngine, Main.kt, écrans Accueil/Résultats/Historique : non modifiés
  • Pas de dépendance nouvelle ; pas de commentaire dans le code ; interface et docs du projet en français
  • Touches gérées uniquement (décision produit) : 0-9, Entrée, Backspace. Pas d'Échap.

Task 1: GameKeyMapping (logique pure) + tests

Files:

  • Create: sharedUI/src/commonMain/kotlin/com/livrets/ui/GameKeyMapping.kt
  • Test: sharedUI/src/commonTest/kotlin/com/livrets/ui/GameKeyMappingTest.kt

Interfaces:

  • Consumes: rien (logique autonome, dépendances Compose Key/KeyEventType déjà dans commonMain via api(libs.compose.ui))

  • Produces: sealed interface KeypadAction { data class Digit(val char: Char); data object Delete; data object Validate } et object GameKeyMapping { fun actionFor(key: Key, type: KeyEventType): KeypadAction? } — utilisés par le Task 2

  • Step 1: Write the failing tests

Créer sharedUI/src/commonTest/kotlin/com/livrets/ui/GameKeyMappingTest.kt :

package com.livrets.ui

import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class GameKeyMappingTest {

    @Test
    fun `chiffres de la rangee du haut`() {
        val expected = mapOf(
            Key.Zero to '0', Key.One to '1', Key.Two to '2', Key.Three to '3',
            Key.Four to '4', Key.Five to '5', Key.Six to '6', Key.Seven to '7',
            Key.Eight to '8', Key.Nine to '9',
        )
        for ((key, char) in expected) {
            assertEquals(KeypadAction.Digit(char), GameKeyMapping.actionFor(key, KeyEventType.KeyDown))
        }
    }

    @Test
    fun `chiffres du pave numerique`() {
        val expected = mapOf(
            Key.NumPad0 to '0', Key.NumPad1 to '1', Key.NumPad2 to '2', Key.NumPad3 to '3',
            Key.NumPad4 to '4', Key.NumPad5 to '5', Key.NumPad6 to '6', Key.NumPad7 to '7',
            Key.NumPad8 to '8', Key.NumPad9 to '9',
        )
        for ((key, char) in expected) {
            assertEquals(KeypadAction.Digit(char), GameKeyMapping.actionFor(key, KeyEventType.KeyDown))
        }
    }

    @Test
    fun `entree valide la reponse`() {
        assertEquals(KeypadAction.Validate, GameKeyMapping.actionFor(Key.Enter, KeyEventType.KeyDown))
    }

    @Test
    fun `entree du pave numerique valide la reponse`() {
        assertEquals(KeypadAction.Validate, GameKeyMapping.actionFor(Key.NumPadEnter, KeyEventType.KeyDown))
    }

    @Test
    fun `backspace efface`() {
        assertEquals(KeypadAction.Delete, GameKeyMapping.actionFor(Key.Backspace, KeyEventType.KeyDown))
    }

    @Test
    fun `relachement de touche ignore`() {
        assertNull(GameKeyMapping.actionFor(Key.One, KeyEventType.KeyUp))
    }

    @Test
    fun `touches non gerees ignorees`() {
        for (key in listOf(Key.A, Key.Escape, Key.Spacebar, Key.Tab)) {
            assertNull(GameKeyMapping.actionFor(key, KeyEventType.KeyDown))
        }
    }
}
  • Step 2: Run tests to verify they fail

Run: ./gradlew :sharedUI:jvmTest Expected: FAIL — compilation échoue avec Unresolved reference 'GameKeyMapping' et Unresolved reference 'KeypadAction' (les types n'existent pas encore).

  • Step 3: Write the minimal implementation

Créer sharedUI/src/commonMain/kotlin/com/livrets/ui/GameKeyMapping.kt :

package com.livrets.ui

import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType

sealed interface KeypadAction {
    data class Digit(val char: Char) : KeypadAction
    data object Delete : KeypadAction
    data object Validate : KeypadAction
}

object GameKeyMapping {
    fun actionFor(key: Key, type: KeyEventType): KeypadAction? {
        if (type != KeyEventType.KeyDown) return null
        return when (key) {
            Key.Zero -> KeypadAction.Digit('0')
            Key.One -> KeypadAction.Digit('1')
            Key.Two -> KeypadAction.Digit('2')
            Key.Three -> KeypadAction.Digit('3')
            Key.Four -> KeypadAction.Digit('4')
            Key.Five -> KeypadAction.Digit('5')
            Key.Six -> KeypadAction.Digit('6')
            Key.Seven -> KeypadAction.Digit('7')
            Key.Eight -> KeypadAction.Digit('8')
            Key.Nine -> KeypadAction.Digit('9')
            Key.NumPad0 -> KeypadAction.Digit('0')
            Key.NumPad1 -> KeypadAction.Digit('1')
            Key.NumPad2 -> KeypadAction.Digit('2')
            Key.NumPad3 -> KeypadAction.Digit('3')
            Key.NumPad4 -> KeypadAction.Digit('4')
            Key.NumPad5 -> KeypadAction.Digit('5')
            Key.NumPad6 -> KeypadAction.Digit('6')
            Key.NumPad7 -> KeypadAction.Digit('7')
            Key.NumPad8 -> KeypadAction.Digit('8')
            Key.NumPad9 -> KeypadAction.Digit('9')
            Key.Enter, Key.NumPadEnter -> KeypadAction.Validate
            Key.Backspace -> KeypadAction.Delete
            else -> null
        }
    }
}
  • Step 4: Run the full test suite

Run: ./gradlew :sharedUI:jvmTest Expected: PASS — les 7 nouveaux tests et tous les tests existants (engine, générateur, persistance, navigation) passent.

  • Step 5: Commit
git add sharedUI/src/commonMain/kotlin/com/livrets/ui/GameKeyMapping.kt sharedUI/src/commonTest/kotlin/com/livrets/ui/GameKeyMappingTest.kt
git commit -m "feat: mapping des touches clavier vers les actions du pavé"

Task 2: Câblage clavier dans GameScreen.PlayingContent

Files:

  • Modify: sharedUI/src/commonMain/kotlin/com/livrets/ui/GameScreen.kt:82-140 (fonction PlayingContent et imports)

Interfaces:

  • Consumes: GameKeyMapping.actionFor(key: Key, type: KeyEventType): KeypadAction?, KeypadAction.Digit(char: Char) / Delete / Validate (Task 1) ; engine.onDigit(Char), engine.onDelete(), engine.onValidate() (existants, GameEngine.kt:31-47)

  • Produces: rien de nouveau (comportement utilisateur uniquement)

  • Step 1: Add the imports

En haut de sharedUI/src/commonMain/kotlin/com/livrets/ui/GameScreen.kt, ajouter aux imports existants :

import androidx.compose.foundation.focusable
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
  • Step 2: Wire the key handler in PlayingContent

Dans GameScreen.kt, remplacer le corps de PlayingContent (lignes 81-93 : la signature et l'ouverture du Column avec ses modificateurs actuels) par :

@Composable
private fun PlayingContent(
    engine: GameEngine,
    state: GameState.Playing,
    progress: Float,
    onQuitRequest: () -> Unit,
) {
    val focusRequester = remember { FocusRequester() }
    LaunchedEffect(Unit) { focusRequester.requestFocus() }
    Column(
        modifier = Modifier
            .fillMaxSize()
            .windowInsetsPadding(WindowInsets.safeDrawing)
            .padding(24.dp)
            .focusRequester(focusRequester)
            .focusable()
            .onPreviewKeyEvent { event ->
                when (val action = GameKeyMapping.actionFor(event.key, event.type)) {
                    is KeypadAction.Digit -> {
                        engine.onDigit(action.char)
                        true
                    }
                    KeypadAction.Delete -> {
                        engine.onDelete()
                        true
                    }
                    KeypadAction.Validate -> {
                        engine.onValidate()
                        true
                    }
                    null -> false
                }
            },
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {

Le reste de PlayingContent (Row « Question x / y », LinearProgressIndicator, texte de la question, saisie, Keypad) est inchangé. Le Keypad et FeedbackContent restent inchangés.

Notes de conception (pour l'implémenteur) :

  • focusRequester placé avant focusable dans la chaîne de modificateurs (convention Compose).

  • onPreviewKeyEvent est en phase de descente : même si un bouton du pavé a le focus après un clic souris, la racine voit l'événement en premier ; retourner true le consomme et empêche Entrée d'activer aussi le bouton.

  • LaunchedEffect(Unit) se relance à chaque remontée de PlayingContent (il quitte la composition pendant le feedback) → le focus est ré-acquis à chaque nouvelle question.

  • Pendant le feedback, PlayingContent n'est pas composé → les touches sont ignorées, aucune modification nécessaire.

  • Step 3: Run the full test suite (compilation jvm incluse)

Run: ./gradlew :sharedUI:jvmTest Expected: PASS — compilation OK pour toutes les cibles de test, tous les tests passent (aucun test UI existant n'est affecté).

  • Step 4: Manual verification on desktop

Run: ./gradlew :desktopApp:run Dans la fenêtre : lancer une partie, puis vérifier :

  1. Les chiffres du rangée du haut et du pavé numérique s'affichent dans la zone de saisie.
  2. Backspace efface le dernier chiffre.
  3. Entrée (rangée et pavé) valide : feedback vert/rouge s'affiche.
  4. Entrée sans saisie : rien ne se passe (pas de validation).
  5. Au-delà de 4 chiffres : la saisie ne grossit plus (MAX_ENTRY_LENGTH).
  6. Après le feedback, la question suivante accepte de nouveau les touches (re-focus).
  7. Cliquer d'abord sur un bouton du pavé à la souris, puis Entrée : valide une seule fois (pas de double déclenchement).
  8. La touche Échap ne fait rien ; ⌘Q/fermeture de fenêtre fonctionne comme avant.

Expected: les 8 vérifications passent.

  • Step 5: Commit
git add sharedUI/src/commonMain/kotlin/com/livrets/ui/GameScreen.kt
git commit -m "feat: répondre au clavier (chiffres, Entrée, Backspace) sur desktop"