diff --git a/androidApp/src/main/java/dev/johnoreilly/wordmaster/androidApp/MainActivity.kt b/androidApp/src/main/java/dev/johnoreilly/wordmaster/androidApp/MainActivity.kt index 9c354c8..8d37c88 100644 --- a/androidApp/src/main/java/dev/johnoreilly/wordmaster/androidApp/MainActivity.kt +++ b/androidApp/src/main/java/dev/johnoreilly/wordmaster/androidApp/MainActivity.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key @@ -85,7 +86,10 @@ fun WordMasterView(padding: Modifier) { val lastGuessCorrect by wordMasterService.lastGuessCorrect.collectAsState() val focusManager = LocalFocusManager.current - val focusRequester = remember { FocusRequester() } + // FocusRequesters for every cell to enable precise intra-row navigation (e.g., Backspace behavior) + val cellRequesters = remember { + List(WordMasterService.MAX_NUMBER_OF_GUESSES) { List(WordMasterService.NUMBER_LETTERS) { FocusRequester() } } + } Row(padding.fillMaxSize().padding(16.dp), horizontalArrangement = Center, verticalAlignment = Alignment.CenterVertically) { @@ -98,43 +102,73 @@ fun WordMasterView(padding: Modifier) { horizontalAlignment = Alignment.CenterHorizontally ) { - var modifier = Modifier.width(55.dp).height(55.dp) - if (guessAttempt == 0 && character == 0) { - modifier = modifier.focusRequester(focusRequester) - } + var modifier = Modifier.width(55.dp).height(55.dp).focusRequester(cellRequesters[guessAttempt][character]) TextField( value = boardGuesses[guessAttempt][character], - onValueChange = { - if (it.length <= 1 && guessAttempt == wordMasterService.currentGuessAttempt) { - wordMasterService.setGuess( - guessAttempt, - character, - it.uppercase() - ) - if (it.isNotEmpty() && character < WordMasterService.NUMBER_LETTERS - 1) { - // Only move within the same row; don't advance to the next row until the guess is submitted - focusManager.moveFocus(FocusDirection.Next) + onValueChange = { newValue -> + if (guessAttempt == wordMasterService.currentGuessAttempt) { + val upper = newValue.uppercase() + val capped = if (upper.length > 1) upper.substring(0, 1) else upper + val previous = boardGuesses[guessAttempt][character] + + if (capped != previous) { + wordMasterService.setGuess( + guessAttempt, + character, + capped + ) + } + + if (capped.isNotEmpty()) { + if (character < WordMasterService.NUMBER_LETTERS - 1) { + // Advance to next column in the same row + focusManager.moveFocus(FocusDirection.Next) + } + } else { + // If we deleted the last character in this cell, move back to previous cell in same row + if (previous.isNotEmpty() && character > 0) { + cellRequesters[guessAttempt][character - 1].requestFocus() + } } } }, - modifier = modifier.onKeyEvent { - if (it.type == KeyEventType.KeyUp && (it.key == Key.Enter || it.key == Key.NumPadEnter)) { - if (guessAttempt == wordMasterService.currentGuessAttempt) { - var filled = true - for (c in 0 until WordMasterService.NUMBER_LETTERS) { - if (boardGuesses[guessAttempt][c].isEmpty()) { filled = false; break } + modifier = modifier + .onPreviewKeyEvent { + if (guessAttempt == wordMasterService.currentGuessAttempt && (it.key == Key.Backspace || it.key == Key.Delete) && it.type == KeyEventType.KeyDown) { + val currentVal = boardGuesses[guessAttempt][character] + if (currentVal.isEmpty() && character > 0) { + cellRequesters[guessAttempt][character - 1].requestFocus() + return@onPreviewKeyEvent true } - if (filled) { - wordMasterService.checkGuess() - // After submitting a guess, move focus to the next row's first cell - focusManager.moveFocus(FocusDirection.Next) - return@onKeyEvent true + } + false + } + .onKeyEvent { + if (it.type == KeyEventType.KeyUp && it.key == Key.Backspace) { + if (guessAttempt == wordMasterService.currentGuessAttempt) { + val currentVal = boardGuesses[guessAttempt][character] + if (currentVal.isEmpty() && character > 0) { + cellRequesters[guessAttempt][character - 1].requestFocus() + return@onKeyEvent true + } + } + } else if (it.type == KeyEventType.KeyUp && (it.key == Key.Enter || it.key == Key.NumPadEnter)) { + if (guessAttempt == wordMasterService.currentGuessAttempt) { + var filled = true + for (c in 0 until WordMasterService.NUMBER_LETTERS) { + if (boardGuesses[guessAttempt][c].isEmpty()) { filled = false; break } + } + if (filled) { + wordMasterService.checkGuess() + // After submitting a guess, move focus to the next row's first cell + focusManager.moveFocus(FocusDirection.Next) + return@onKeyEvent true + } } } + false } - false - } .border(1.dp, Color.Black.copy(alpha = 0.6f), androidx.compose.foundation.shape.RoundedCornerShape(10.dp)), singleLine = true, keyboardOptions = KeyboardOptions( @@ -173,9 +207,11 @@ fun WordMasterView(padding: Modifier) { ), ) - DisposableEffect(Unit) { - focusRequester.requestFocus() - onDispose { } + if (guessAttempt == 0 && character == 0) { + DisposableEffect(Unit) { + cellRequesters[0][0].requestFocus() + onDispose { } + } } } } @@ -211,7 +247,7 @@ fun WordMasterView(padding: Modifier) { Spacer(Modifier.width(16.dp)) Button(onClick = { wordMasterService.resetGame() - focusRequester.requestFocus() + cellRequesters[0][0].requestFocus() }) { Text("New Game") } @@ -226,7 +262,7 @@ fun WordMasterView(padding: Modifier) { Button(onClick = { wordMasterService.resetGame() // Re-focus first cell after reset - focusRequester.requestFocus() + cellRequesters[0][0].requestFocus() }) { Text("OK") } diff --git a/compose-desktop/src/main/kotlin/main.kt b/compose-desktop/src/main/kotlin/main.kt index b829194..326a08e 100644 --- a/compose-desktop/src/main/kotlin/main.kt +++ b/compose-desktop/src/main/kotlin/main.kt @@ -46,15 +46,14 @@ fun WordMasterView() { val lastGuessCorrect by wordMasterService.lastGuessCorrect.collectAsState() val focusManager = LocalFocusManager.current - // FocusRequesters for the first cell of each row to allow precise focusing after submission - val rowFirstCellRequesters = remember { List(WordMasterService.MAX_NUMBER_OF_GUESSES) { FocusRequester() } } + // FocusRequesters for every cell to precisely control focus navigation within rows + val cellRequesters = remember { List(WordMasterService.MAX_NUMBER_OF_GUESSES) { List(WordMasterService.NUMBER_LETTERS) { FocusRequester() } } } // Ensure focus shifts to the first cell of the current row after a guess submission/recomposition val currentAttempt = wordMasterService.currentGuessAttempt LaunchedEffect(currentAttempt) { if (currentAttempt in 0 until WordMasterService.MAX_NUMBER_OF_GUESSES) { - // Post-recomposition, request focus on the first cell of the active row - rowFirstCellRequesters[currentAttempt].requestFocus() + cellRequesters[currentAttempt][0].requestFocus() } } @@ -73,13 +72,10 @@ fun WordMasterView() { // Move focus explicitly to next row’s first cell val nextRow = current + 1 if (nextRow < WordMasterService.MAX_NUMBER_OF_GUESSES) { - rowFirstCellRequesters[nextRow].requestFocus() + cellRequesters[nextRow][0].requestFocus() } true } else false - } else if (it.key == Key.Backspace) { - focusManager.moveFocus(FocusDirection.Previous) - true } else { false } @@ -97,9 +93,7 @@ fun WordMasterView() { .padding(2.dp) .width(64.dp) .height(64.dp) - if (character == 0) { - modifier = modifier.focusRequester(rowFirstCellRequesters[guessAttempt]) - } + modifier = modifier.focusRequester(cellRequesters[guessAttempt][character]) TextField( enabled = guessAttempt == wordMasterService.currentGuessAttempt, @@ -121,7 +115,15 @@ fun WordMasterView() { } } }, - modifier = modifier.border(1.dp, Black.copy(alpha = 0.6f), RoundedCornerShape(10.dp)), + modifier = modifier.border(1.dp, Black.copy(alpha = 0.6f), RoundedCornerShape(10.dp)).onKeyEvent { + if (it.key == Key.Backspace && guessAttempt == wordMasterService.currentGuessAttempt) { + val currentVal = boardGuesses[guessAttempt][character] + if (currentVal.isEmpty() && character > 0) { + cellRequesters[guessAttempt][character - 1].requestFocus() + true + } else false + } else false + }, singleLine = true, textStyle = TextStyle(fontSize = 20.sp, textAlign = TextAlign.Center), colors = TextFieldDefaults.textFieldColors( @@ -136,7 +138,7 @@ fun WordMasterView() { if (guessAttempt == 0 && character == 0) { DisposableEffect(Unit) { - rowFirstCellRequesters[0].requestFocus() + cellRequesters[0][0].requestFocus() onDispose { } } } @@ -165,7 +167,7 @@ fun WordMasterView() { // Move focus explicitly to next row’s first cell val nextRow = current + 1 if (nextRow < WordMasterService.MAX_NUMBER_OF_GUESSES) { - rowFirstCellRequesters[nextRow].requestFocus() + cellRequesters[nextRow][0].requestFocus() } } }) { @@ -174,7 +176,7 @@ fun WordMasterView() { Spacer(Modifier.width(16.dp)) Button(onClick = { wordMasterService.resetGame() - rowFirstCellRequesters[0].requestFocus() + cellRequesters[0][0].requestFocus() }) { Text("New Game") } @@ -188,7 +190,7 @@ fun WordMasterView() { confirmButton = { Button(onClick = { wordMasterService.resetGame() - rowFirstCellRequesters[0].requestFocus() + cellRequesters[0][0].requestFocus() }) { Text("OK") } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index c582e61..efd795c 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -23,7 +23,8 @@ struct ContentView: View { let upper = newValue.uppercased() let capped = String(upper.prefix(1)) - if capped != viewModel.getGuess(guessAttempt: guessNumber, character: character) { + let previous = viewModel.getGuess(guessAttempt: guessNumber, character: character) + if capped != previous { viewModel.setGuess(guessAttempt: guessNumber, character: character, guess: capped) // Move focus to the next cell when a single character is entered @@ -34,8 +35,13 @@ struct ContentView: View { DispatchQueue.main.async { focusedPos = FocusPos(row: guessNumber, col: nextCol) } - } else { - // Optionally keep focus or move to next row's first cell; we'll keep it here + } + } else { + // If we deleted the last character in this cell, move back to previous cell in same row + if !previous.isEmpty && character > 0 { + DispatchQueue.main.async { + focusedPos = FocusPos(row: guessNumber, col: character - 1) + } } } } diff --git a/iosApp/iosApp/ViewModel.swift b/iosApp/iosApp/ViewModel.swift index 4d0419d..2422fe5 100644 --- a/iosApp/iosApp/ViewModel.swift +++ b/iosApp/iosApp/ViewModel.swift @@ -43,7 +43,7 @@ class ViewModel: ObservableObject { do { let stream = asyncSequence(for: wordMasterService.revealedAnswer) for try await data in stream { - self.revealedAnswer = data as? String + self.revealedAnswer = data } } catch { print("Failed with error: \(error)")