docs: plan clavier physique sur la version desktop
This commit is contained in:
1 file changed
+266
@@ -0,0 +1,266 @@
|
|||||||
|
# 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"
|
||||||
|
```
|
||||||
Reference in new issue
Block a user