11 KiB
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 danscommonTest) - 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.keyetevent.typesont 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/KeyEventTypedéjà dans commonMain viaapi(libs.compose.ui)) -
Produces:
sealed interface KeypadAction { data class Digit(val char: Char); data object Delete; data object Validate }etobject 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(fonctionPlayingContentet 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) :
-
focusRequesterplacé avantfocusabledans la chaîne de modificateurs (convention Compose). -
onPreviewKeyEventest 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 ; retournertruele consomme et empêche Entrée d'activer aussi le bouton. -
LaunchedEffect(Unit)se relance à chaque remontée dePlayingContent(il quitte la composition pendant le feedback) → le focus est ré-acquis à chaque nouvelle question. -
Pendant le feedback,
PlayingContentn'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 :
- Les chiffres du rangée du haut et du pavé numérique s'affichent dans la zone de saisie.
- Backspace efface le dernier chiffre.
- Entrée (rangée et pavé) valide : feedback vert/rouge s'affiche.
- Entrée sans saisie : rien ne se passe (pas de validation).
- Au-delà de 4 chiffres : la saisie ne grossit plus (
MAX_ENTRY_LENGTH). - Après le feedback, la question suivante accepte de nouveau les touches (re-focus).
- Cliquer d'abord sur un bouton du pavé à la souris, puis Entrée : valide une seule fois (pas de double déclenchement).
- 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"