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

267 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` :
```kotlin
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` :
```kotlin
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**
```bash
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 :
```kotlin
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 :
```kotlin
@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**
```bash
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"
```