From 93103e4e498b188e21dd63343a6c494133549f4e Mon Sep 17 00:00:00 2001 From: Christian Basler Date: Mon, 2 Jun 2025 06:06:11 +0200 Subject: [PATCH] Add Keyboard Control --- .../ch/dissem/yaep/android/MainActivity.kt | 5 +- .../kotlin/ch/dissem/yaep/ui/common/App.kt | 60 +++- .../yaep/ui/common/adaptive game layout.kt | 338 ------------------ .../kotlin/ch/dissem/yaep/ui/common/end.kt | 11 + .../yaep/ui/common/focus/CluesFocusable.kt | 7 + .../ui/common/focus/CluesSelectionManager.kt | 72 ++++ .../common/focus/FocusFollowingFocusable.kt | 21 ++ .../focus/FocusFollowingSelectionManager.kt | 24 ++ .../dissem/yaep/ui/common/focus/Focusable.kt | 27 ++ .../yaep/ui/common/focus/GridFocusable.kt | 11 + .../ui/common/focus/GridSelectionManager.kt | 83 +++++ .../yaep/ui/common/focus/LinearFocusable.kt | 21 ++ .../ui/common/focus/LinearSelectionManager.kt | 52 +++ .../yaep/ui/common/focus/SelectionManager.kt | 61 ++++ .../kotlin/ch/dissem/yaep/ui/common/grid.kt | 172 +++++++-- .../yaep/ui/common/layout/AspectRatio.kt | 21 ++ .../yaep/ui/common/layout/FocusGroup.kt | 17 + .../ui/common/layout/adaptive game layout.kt | 162 +++++++++ .../dissem/yaep/ui/common/layout/focus box.kt | 44 +++ .../dissem/yaep/ui/common/layout/landscape.kt | 99 +++++ .../dissem/yaep/ui/common/layout/portrait.kt | 114 ++++++ .../dissem/yaep/ui/common/layout/squarish.kt | 91 +++++ .../kotlin/ch/dissem/yaep/ui/common/math.kt | 8 + .../kotlin/ch/dissem/yaep/ui/common/names.kt | 6 +- .../ch/dissem/yaep/ui/common/selector.kt | 11 +- .../ch/dissem/yaep/ui/common/theme/Color.kt | 1 + .../ch/dissem/yaep/ui/common/widget status.kt | 39 ++ .../ch/dissem/yaep/ui/common/AppTest.kt | 181 ++++++++++ .../ch/dissem/yaep/ui/common/NamesTest.kt | 66 ++++ .../common/focus/CluesSelectionManagerTest.kt | 96 +++++ .../FocusFollowingSelectionManagerTest.kt | 67 ++++ .../common/focus/GridSelectionManagerTest.kt | 80 +++++ .../focus/LinearSelectionManagerTest.kt | 73 ++++ .../ui/common/focus/SelectionManagerTest.kt | 59 +++ .../yaep/ui/common/focus/keybord utils.kt | 12 + .../dissem/yaep/ui/desktop/desktop window.kt | 4 + .../kotlin/ch/dissem/yaep/ui/desktop/main.kt | 5 +- .../kotlin/ch/dissem/yaep/domain/GameRow.kt | 21 +- 38 files changed, 1824 insertions(+), 418 deletions(-) delete mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/adaptive game layout.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/CluesFocusable.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManager.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingFocusable.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManager.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/Focusable.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridFocusable.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManager.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/LinearFocusable.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManager.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManager.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/AspectRatio.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/FocusGroup.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/adaptive game layout.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/focus box.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/landscape.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/portrait.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/squarish.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/math.kt create mode 100644 commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/widget status.kt create mode 100644 commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/AppTest.kt create mode 100644 commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/NamesTest.kt create mode 100644 commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManagerTest.kt create mode 100644 commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManagerTest.kt create mode 100644 commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManagerTest.kt create mode 100644 commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManagerTest.kt create mode 100644 commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManagerTest.kt create mode 100644 commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/keybord utils.kt diff --git a/android/src/main/kotlin/ch/dissem/yaep/android/MainActivity.kt b/android/src/main/kotlin/ch/dissem/yaep/android/MainActivity.kt index 1510fc0..9e752d0 100644 --- a/android/src/main/kotlin/ch/dissem/yaep/android/MainActivity.kt +++ b/android/src/main/kotlin/ch/dissem/yaep/android/MainActivity.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import ch.dissem.yaep.domain.generateGame import ch.dissem.yaep.ui.common.App +import ch.dissem.yaep.ui.common.focus.FocusFollowingSelectionManager import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import yaep.commonui.generated.resources.action_restart @@ -63,8 +64,8 @@ class MainActivity : ComponentActivity() { ) { insets -> App( modifier = Modifier.padding(insets), + rootSelectionManager = FocusFollowingSelectionManager, spacing = 4.dp, - selectDirectly = false, game = game, onNewGame = { game = generateGame() }, resetCluesBeacon = resetCluesBeacon @@ -83,7 +84,7 @@ fun AppAndroidPreview() { App( game = game, spacing = 4.dp, - selectDirectly = false, + rootSelectionManager = FocusFollowingSelectionManager, onNewGame = { game = generateGame() }, resetCluesBeacon = resetCluesBeacon ) diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/App.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/App.kt index 318952a..4116a6f 100644 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/App.kt +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/App.kt @@ -15,17 +15,20 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import ch.dissem.yaep.domain.Game +import ch.dissem.yaep.ui.common.focus.FocusFollowingFocusable +import ch.dissem.yaep.ui.common.focus.SelectionManager +import ch.dissem.yaep.ui.common.layout.AdaptiveGameLayout import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlin.coroutines.CoroutineContext import kotlin.time.ExperimentalTime - @Composable @OptIn(ExperimentalTime::class) fun App( modifier: Modifier = Modifier, - selectDirectly: Boolean, + rootSelectionManager: SelectionManager, spacing: Dp, game: Game, onNewGame: () -> Unit, @@ -54,45 +57,70 @@ fun App( Box(modifier = modifier) { AdaptiveGameLayout( modifier = Modifier.blurOnFinished(isSolved), - grid = { + selectionManager = rootSelectionManager, + grid = { selectionManager -> PuzzleGrid( + selectionManager = selectionManager, grid = game.grid, spacing = spacing, - selectDirectly = selectDirectly, onUpdate = { horizontalClues.forEach { it.update(game.grid) } verticalClues.forEach { it.update(game.grid) } } ) }, - horizontalClues = { + horizontalClues = { selectionManager -> for (clue in horizontalClues) { HorizontalClue( - modifier = Modifier.forClue(clue, spacing), + modifier = Modifier + .focus(remember(selectionManager) { + selectionManager.add( + primaryAction = { + clue.isActive = !clue.isActive + }, + secondaryAction = { + clue.isActive = false + } + ) + }) + .forClue(clue, spacing), spacing = spacing, clue = clue.clue, isClueViolated = clue.isViolated ) } }, - verticalClues = { - for (clue in verticalClues) { - VerticalClue( - modifier = Modifier.forClue(clue, spacing), - spacing = spacing, - clue = clue.clue, - isClueViolated = clue.isViolated - ) + verticalClues = { selectionManager -> + if (verticalClues.isNotEmpty()) { + for (clue in verticalClues) { + VerticalClue( + modifier = Modifier + .focus(remember(selectionManager) { + selectionManager.add( + primaryAction = { + clue.isActive = !clue.isActive + }, + secondaryAction = { + clue.isActive = false + } + ) + }) + .forClue(clue, spacing), + spacing = spacing, + clue = clue.clue, + isClueViolated = clue.isViolated + ) + } } }, time = { Text( time, - fontSize = TextUnit(4f, TextUnitType.Companion.Em), + fontSize = TextUnit(4f, TextUnitType.Em), textAlign = TextAlign.End ) }, - spacing = spacing + game, resetCluesBeacon ) EndOfGame(isSolved = isSolved, time = time, onRestart = onNewGame) } diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/adaptive game layout.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/adaptive game layout.kt deleted file mode 100644 index f446f55..0000000 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/adaptive game layout.kt +++ /dev/null @@ -1,338 +0,0 @@ -package ch.dissem.yaep.ui.common - -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.Placeable -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Constraints.Companion.fixed -import androidx.compose.ui.unit.Constraints.Companion.fixedWidth -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import ch.dissem.yaep.ui.common.AspectRatio.LANDSCAPE -import ch.dissem.yaep.ui.common.AspectRatio.PORTRAIT -import ch.dissem.yaep.ui.common.AspectRatio.SQUARISH -import kotlin.math.max -import kotlin.math.min - - -private enum class AspectRatio { - PORTRAIT, LANDSCAPE, SQUARISH; - - companion object { - private const val ASPECT_RATIO_LANDSCAPE = 1.4f - private const val ASPECT_RATIO_PORTRAIT = 1 / ASPECT_RATIO_LANDSCAPE - - fun from(constraints: Constraints): AspectRatio { - val ratio = constraints.maxWidth.toFloat() / constraints.maxHeight.toFloat() - return when { - ratio < ASPECT_RATIO_PORTRAIT -> PORTRAIT - ratio > ASPECT_RATIO_LANDSCAPE -> LANDSCAPE - else -> SQUARISH - } - } - } -} - -@Composable -fun AdaptiveGameLayout( - modifier: Modifier = Modifier, - grid: @Composable () -> Unit, - horizontalClues: @Composable () -> Unit, - verticalClues: @Composable () -> Unit, - time: @Composable () -> Unit, - divider: @Composable () -> Unit = { HorizontalDivider() }, - spacing: Dp = 8.dp -) { - Layout( - contents = listOf(grid, horizontalClues, verticalClues, time, divider, divider), - modifier = modifier - ) { measurables, constraints -> - layout(width = constraints.maxWidth, height = constraints.maxHeight) { - val aspectRatio = AspectRatio.from(constraints) - - val gridMeasurable = measurables[0][0] - val horizontalCluesMeasurables = measurables[1] - val verticalCluesMeasurables = measurables[2] - val timeMeasurable = measurables[3][0] - val dividerMeasurable = measurables[4][0] - - val spacingPx = spacing.roundToPx() - - when (aspectRatio) { - PORTRAIT -> { - val divider2Measurable = measurables[5][0] - portrait( - constraints, - spacingPx, - gridMeasurable, - horizontalCluesMeasurables, - verticalCluesMeasurables, - timeMeasurable, - dividerMeasurable, - divider2Measurable - ) - } - - SQUARISH -> { - squarish( - constraints, - spacingPx, - gridMeasurable, - horizontalCluesMeasurables, - verticalCluesMeasurables, - timeMeasurable - ) - } - - LANDSCAPE -> { - landscape( - constraints, - spacingPx, - gridMeasurable, - horizontalCluesMeasurables, - verticalCluesMeasurables, - timeMeasurable, - dividerMeasurable - ) - } - } - } - } -} - -private fun Placeable.PlacementScope.portrait( - constraints: Constraints, - spacingPx: Int, - gridMeasurable: Measurable, - horizontalCluesMeasurables: List, - verticalCluesMeasurables: List, - timeMeasurable: Measurable, - divider1Measurable: Measurable, - divider2Measurable: Measurable -) { - val gridSize = constraints.maxWidth - val gridItemSize = (gridSize - 12 * spacingPx) / 18 - - val gridConstraints = fixed(gridSize, gridSize) - val horizontalCluesConstraints = fixed( - width = 3 * gridItemSize + 2 * spacingPx, - height = gridItemSize + 2 * spacingPx - ) - val verticalCluesConstraints = fixed( - width = gridItemSize + 2 * spacingPx, - height = 3 * gridItemSize + 2 * spacingPx - ) - val timeConstraints = Constraints() - val dividerConstraints = fixedWidth(gridSize) - - val gridPlaceable = gridMeasurable.measure(gridConstraints) - val horizontalCluesPlaceables = horizontalCluesMeasurables.map { - it.measure(horizontalCluesConstraints) - } - val verticalCluesPlaceables = verticalCluesMeasurables.map { - it.measure(verticalCluesConstraints) - } - val timePlaceable = timeMeasurable.measure(timeConstraints) - val divider1Placeable = divider1Measurable.measure(dividerConstraints) - val divider2Placeable = divider2Measurable.measure(dividerConstraints) - - // Position the grid - gridPlaceable.place(0, 0) - - divider1Placeable.place(0, gridSize + spacingPx) - - // Position the horizontal clues - var offsetY = placeClues( - placeables = horizontalCluesPlaceables, - offsetX = 0, - offsetY = gridSize + 2 * spacingPx, - maxWidth = gridSize - ) - - // Add divider in between - divider2Placeable.place(0, offsetY + spacingPx) - - // Position the vertical clues - offsetY = placeClues( - placeables = verticalCluesPlaceables, - offsetX = 0, - offsetY = offsetY + spacingPx + divider2Placeable.height, - maxWidth = gridSize - ) - - // Position the time - val remainingSpace = constraints.maxHeight - offsetY - if (remainingSpace < timePlaceable.height) { - val scale = remainingSpace.toFloat() / timePlaceable.height.toFloat() - if (scale > 0.1f) { - timePlaceable.placeWithLayer( - x = constraints.maxWidth - timePlaceable.width - spacingPx, - y = constraints.maxHeight - timePlaceable.height - ) { - scaleX = scale - scaleY = scale - translationX = (timePlaceable.width * (1 - scale)) / 2f - translationY = (timePlaceable.height * (1 - scale)) / 2f - } - } - } else { - timePlaceable.place( - x = constraints.maxWidth - timePlaceable.width - spacingPx, - y = constraints.maxHeight - timePlaceable.height - ) - } -} - -private fun Placeable.PlacementScope.squarish( - constraints: Constraints, - spacingPx: Int, - gridMeasurable: Measurable, - horizontalCluesMeasurables: List, - verticalCluesMeasurables: List, - timeMeasurable: Measurable -) { - val gridSize = (7 * min(constraints.maxWidth, constraints.maxHeight)) / 10 - val gridItemSize = (gridSize - 12 * spacingPx) / 18 - val rightBarWidth = constraints.maxWidth - gridSize - spacingPx - - val gridConstraints = fixed(gridSize, gridSize) - val horizontalCluesConstraints = fixed( - width = 3 * gridItemSize + 2 * spacingPx, - height = gridItemSize + 2 * spacingPx - ) - val verticalCluesConstraints = fixed( - width = gridItemSize + 2 * spacingPx, - height = 3 * gridItemSize + 2 * spacingPx - ) - val timeConstraints = Constraints() - - val gridPlaceable = gridMeasurable.measure(gridConstraints) - val horizontalCluesPlaceables = horizontalCluesMeasurables.map { - it.measure(horizontalCluesConstraints) - } - val verticalCluesPlaceables = verticalCluesMeasurables.map { - it.measure(verticalCluesConstraints) - } - val timePlaceable = timeMeasurable.measure(timeConstraints) - - // Position the grid - gridPlaceable.place(0, 0) - - // Position the horizontal clues - placeClues( - placeables = horizontalCluesPlaceables, - offsetX = gridSize + 2 * spacingPx, - offsetY = 0, - maxWidth = rightBarWidth - ) - - // Position the vertical clues - placeClues( - placeables = verticalCluesPlaceables, - offsetX = 0, - offsetY = gridSize + 2 * spacingPx, - maxWidth = gridSize - ) - - // Position the time - timePlaceable.place( - x = constraints.maxWidth - timePlaceable.width - spacingPx, - y = constraints.maxHeight - timePlaceable.height - ) -} - -private fun Placeable.PlacementScope.landscape( - constraints: Constraints, - spacingPx: Int, - gridMeasurable: Measurable, - horizontalCluesMeasurables: List, - verticalCluesMeasurables: List, - timeMeasurable: Measurable, - dividerMeasurable: Measurable -) { - val gridSize = constraints.maxHeight - val gridItemSize = (gridSize - 12 * spacingPx) / 18 - val rightBarWidth = constraints.maxWidth - gridSize - 2 * spacingPx - - val gridConstraints = fixed(gridSize, gridSize) - val baseSpace = gridSize - 2 * spacingPx - val horizontalCluesConstraints = fixed( - width = 3 * gridItemSize + 2 * spacingPx, - height = gridItemSize + 2 * spacingPx - ) - val verticalCluesConstraints = fixed( - width = gridItemSize + 2 * spacingPx, - height = 3 * gridItemSize + 2 * spacingPx - ) - val timeConstraints = Constraints.fixedHeight(baseSpace / 10) - val dividerConstraints = fixedWidth(rightBarWidth - 2 * spacingPx) - - val gridPlaceable = gridMeasurable.measure(gridConstraints) - val horizontalCluesPlaceables = horizontalCluesMeasurables.map { - it.measure(horizontalCluesConstraints) - } - val verticalCluesPlaceables = verticalCluesMeasurables.map { - it.measure(verticalCluesConstraints) - } - val timePlaceable = timeMeasurable.measure(timeConstraints) - val dividerPlaceable = dividerMeasurable.measure(dividerConstraints) - - // Position the grid - gridPlaceable.place(0, 0) - - // Position the horizontal clues - val offsetY = placeClues( - placeables = horizontalCluesPlaceables, - offsetX = gridSize + 2 * spacingPx, - offsetY = 0, - maxWidth = rightBarWidth - ) - - // Add divider in between - dividerPlaceable.place(gridSize + 3 * spacingPx, offsetY + spacingPx) - - // Position the vertical clues - placeClues( - placeables = verticalCluesPlaceables, - offsetX = gridSize + 2 * spacingPx, - offsetY = offsetY + spacingPx + dividerPlaceable.height, - maxWidth = rightBarWidth - ) - - // Position the time - timePlaceable.place( - x = constraints.maxWidth - timePlaceable.width - spacingPx, - y = constraints.maxHeight - timePlaceable.height - ) -} - -private fun Placeable.PlacementScope.placeClues( - placeables: List, - offsetX: Int, - offsetY: Int, - maxWidth: Int -): Int { - if (placeables.isEmpty()) return offsetY - - val itemWidth = placeables.first().width - val itemHeight = placeables.first().height - val columns = max(1, maxWidth / itemWidth) - val spacing = if (columns == 1) 0 else (maxWidth - columns * itemWidth) / (columns - 1) - var currentX = offsetX - var currentY = offsetY - var i = 0 - for (placeable in placeables) { - placeable.place(currentX, currentY) - currentX += itemWidth + spacing - i++ - if (i % columns == 0 && i < placeables.size) { - currentX = offsetX - currentY += itemHeight - } - } - return currentY + itemHeight -} - diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/end.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/end.kt index 56ad5f9..4ab0752 100644 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/end.kt +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/end.kt @@ -13,10 +13,14 @@ import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.BlurEffect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.testTag @@ -36,6 +40,12 @@ fun EndOfGame( time: String, onRestart: () -> Unit ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(isSolved) { + if (isSolved) { + focusRequester.requestFocus() + } + } AnimatedVisibility( visible = isSolved, modifier = modifier, @@ -72,6 +82,7 @@ fun EndOfGame( Spacer(modifier = Modifier.height(32.dp)) Button( modifier = Modifier.align(CenterHorizontally) + .focusRequester(focusRequester) .testTag("EndOfGame.restart"), onClick = onRestart ) { diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/CluesFocusable.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/CluesFocusable.kt new file mode 100644 index 0000000..7eaabbf --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/CluesFocusable.kt @@ -0,0 +1,7 @@ +package ch.dissem.yaep.ui.common.focus + +class CluesFocusable( + manager: CluesSelectionManager, + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? +) : Focusable(manager, primaryAction, secondaryAction) \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManager.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManager.kt new file mode 100644 index 0000000..3c90f4b --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManager.kt @@ -0,0 +1,72 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.key +import ch.dissem.yaep.ui.common.ceilDiv +import kotlin.math.max +import kotlin.math.min + +class CluesSelectionManager : SelectionManager() { + var columns: Int = 1 + + private var row: Int = 0 + private var col: Int = 0 + + private val focusables = mutableListOf() + + override fun add( + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? + ): CluesFocusable { + val new = CluesFocusable( + manager = this, + primaryAction = primaryAction, + secondaryAction = secondaryAction + ) + focusables.add(new) + return new + } + + override fun doOnKeyEvent(event: KeyEvent): Boolean { + val rows = focusables.size ceilDiv columns + + when (event.key) { + Key.DirectionDown -> { + row++ + if (row >= rows) { + row = 0 + } + } + + Key.DirectionUp -> { + row-- + if (row < 0) { + row = rows - 1 + } + } + + Key.DirectionRight -> { + col++ + if (col >= columns) { + col = 0 + } + } + + Key.DirectionLeft -> { + col-- + if (col < 0) { + col = columns - 1 + } + } + + else -> return focused?.child?.onKeyEvent(event) == true + } + + // This makes sure the limits aren't exceeded when values are changed concurrently + val index = max(0, min(row * columns + col, focusables.size - 1)) + focused = focusables[index] + + return true + } +} \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingFocusable.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingFocusable.kt new file mode 100644 index 0000000..3ec0bfb --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingFocusable.kt @@ -0,0 +1,21 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState + +class FocusFollowingFocusable( + manager: FocusFollowingSelectionManager, + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? +) : Focusable(manager, primaryAction, secondaryAction) { + + val focusRequester = FocusRequester() + + fun setFocus(state: FocusState) { + if (state.hasFocus) { + manager.focused = this + } else if (manager.focused === this) { + manager.focused = null + } + } +} \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManager.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManager.kt new file mode 100644 index 0000000..e0ae99f --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManager.kt @@ -0,0 +1,24 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.input.key.KeyEvent + +object FocusFollowingSelectionManager : SelectionManager() { + init { + isActive = true + } + + override fun add( + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? + ): FocusFollowingFocusable { + return FocusFollowingFocusable( + manager = this, + primaryAction = primaryAction, + secondaryAction = secondaryAction + ) + } + + // Key events are ignored, the default focus mechanisms are used + override fun doOnKeyEvent(event: KeyEvent): Boolean = focused?.child?.onKeyEvent(event) == true + +} \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/Focusable.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/Focusable.kt new file mode 100644 index 0000000..d49571e --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/Focusable.kt @@ -0,0 +1,27 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.input.key.KeyEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +abstract class Focusable>( + protected val manager: SelectionManager, + val primaryAction: (() -> Unit)?, + val secondaryAction: (() -> Unit)?, + var onKeyEvent: ((KeyEvent) -> Boolean)? = null +) { + + val hasFocus: Flow = + combine(manager.isActiveFlow, manager.focusedFlow) { isActive, focused -> + isActive && focused == this + } + + var child: SelectionManager<*>? = null + private set + + fun > create(selectionManager: M): M = selectionManager.apply { + isActive = manager.isActive && manager.focused == this@Focusable + this@Focusable.child = this + } + +} \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridFocusable.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridFocusable.kt new file mode 100644 index 0000000..6c4f71c --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridFocusable.kt @@ -0,0 +1,11 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.input.key.KeyEvent + +class GridFocusable( + manager: GridSelectionManager, + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)?, + onKeyEvent: ((KeyEvent) -> Boolean)? = null, + val position: String +) : Focusable(manager, primaryAction, secondaryAction, onKeyEvent) \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManager.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManager.kt new file mode 100644 index 0000000..37bc37c --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManager.kt @@ -0,0 +1,83 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.key + +class GridSelectionManager : SelectionManager() { + + val grid = mutableListOf>() + + var row = 0 + var col = 0 + + fun addRow(): GridSelectionManager { + grid.add(mutableListOf()) + return this + } + + fun add(onKeyEvent: (KeyEvent) -> Boolean): GridFocusable { + val new = GridFocusable( + manager = this, + primaryAction = null, + secondaryAction = null, + onKeyEvent = onKeyEvent, + position = "${grid.last().size + 1}/${grid.size}" + ) + grid.last().add(new) + return new + } + + override fun add( + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? + ): GridFocusable { + val new = GridFocusable( + manager = this, + primaryAction = primaryAction, + secondaryAction = secondaryAction, + position = "${grid.last().size + 1}/${grid.size}" + ) + grid.last().add(new) + return new + } + + override fun doOnKeyEvent(event: KeyEvent): Boolean { + if (grid.isEmpty()) return false + + when (event.key) { + Key.DirectionDown -> { + row++ + if (row >= grid.size) { + row = 0 + } + } + + Key.DirectionUp -> { + row-- + if (row < 0) { + row = grid.size - 1 + } + } + + Key.DirectionRight -> { + col++ + if (col >= grid[row].size) { + col = 0 + } + } + + Key.DirectionLeft -> { + col-- + if (col < 0) { + col = grid[row].size - 1 + } + } + + else -> return focused?.child?.onKeyEvent(event) == true + } + + focused = grid[row][col] + return true + } +} \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/LinearFocusable.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/LinearFocusable.kt new file mode 100644 index 0000000..d6e8ae8 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/LinearFocusable.kt @@ -0,0 +1,21 @@ +package ch.dissem.yaep.ui.common.focus + +class LinearFocusable( + manager: LinearSelectionManager, + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? +) : Focusable(manager, primaryAction, secondaryAction) { + + var previous: LinearFocusable = this + private set + + private var _next: LinearFocusable = this + var next: LinearFocusable + get() = _next + set(value) { + previous = value.previous + previous._next = this + value.previous = this + _next = value + } +} \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManager.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManager.kt new file mode 100644 index 0000000..809a9c0 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManager.kt @@ -0,0 +1,52 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key + +class LinearSelectionManager( + val keyNext: Key, + val keyPrevious: Key? = null +) : SelectionManager() { + + private fun focusNext() { + focused = focused?.next + } + + private fun focusPrevious() { + focused = focused?.previous + } + + override fun add( + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? + ): LinearFocusable { + val new = LinearFocusable( + manager = this, + primaryAction = primaryAction, + secondaryAction = secondaryAction + ) + if (focused != null) { + new.next = focused!! + } else { + focused = new + } + return new + } + + override fun doOnKeyEvent(event: KeyEvent): Boolean { + if (event.key == keyNext) { + if (keyPrevious == null && event.isShiftPressed) { + focusPrevious() + } else { + focusNext() + } + } else if (event.key == keyPrevious) { + focusPrevious() + } else { + return focused?.child?.onKeyEvent(event) == true + } + return true + } +} \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManager.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManager.kt new file mode 100644 index 0000000..ac08171 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManager.kt @@ -0,0 +1,61 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import kotlinx.coroutines.flow.MutableStateFlow + +abstract class SelectionManager> { + var isActiveFlow = MutableStateFlow(false) + var isActive: Boolean + get() = isActiveFlow.value + set(value) { + isActiveFlow.value = value + } + + var focusedFlow = MutableStateFlow(null) + + var focused: F? + get() = focusedFlow.value + set(value) { + val previous = focusedFlow.value + if (previous != value) { + previous?.child?.isActive = false + value?.child?.isActive = true + focusedFlow.value = value + } + } + + fun add(): F = add(null, null) + + fun add(primaryAction: () -> Unit): F = add(primaryAction, null) + + abstract fun add( + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? + ): F + + fun onKeyEvent(event: KeyEvent): Boolean { + if (event.type != KeyEventType.KeyUp) return false + + return when (event.key) { + Key.Spacebar, Key.Enter -> { + focused?.primaryAction?.invoke() != null || + focused?.onKeyEvent?.invoke(event) == true || + focused?.child?.onKeyEvent(event) == true + } + + Key.Delete, Key.Backspace -> { + focused?.secondaryAction?.invoke() != null || + focused?.onKeyEvent?.invoke(event) == true || + focused?.child?.onKeyEvent(event) == true + } + + else -> focused?.onKeyEvent?.invoke(event) == true || doOnKeyEvent(event) + } + } + + protected abstract fun doOnKeyEvent(event: KeyEvent): Boolean +} \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/grid.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/grid.kt index 8a75304..12d1d6e 100644 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/grid.kt +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/grid.kt @@ -2,6 +2,7 @@ package ch.dissem.yaep.ui.common import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight @@ -12,6 +13,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import ch.dissem.yaep.domain.GameCell @@ -19,11 +25,12 @@ import ch.dissem.yaep.domain.GameRow import ch.dissem.yaep.domain.Grid import ch.dissem.yaep.domain.Item import ch.dissem.yaep.domain.ItemClass +import ch.dissem.yaep.ui.common.focus.GridSelectionManager @Composable fun PuzzleGrid( modifier: Modifier = Modifier, - selectDirectly: Boolean, + selectionManager: GridSelectionManager, spacing: Dp = 8.dp, grid: Grid, onUpdate: () -> Unit @@ -36,7 +43,7 @@ fun PuzzleGrid( onSnapshot = { grid.snapshot() }, onUndo = { grid.undo() }, spacing = spacing, - selectDirectly = selectDirectly + selectionManager = remember(selectionManager) { selectionManager.addRow() } ) } } @@ -49,52 +56,147 @@ private fun PuzzleRow( onSnapshot: () -> Unit, onUndo: () -> Boolean, spacing: Dp, - selectDirectly: Boolean + selectionManager: GridSelectionManager ) { Row( modifier = Modifier .fillMaxWidth() .wrapContentHeight() ) { - val allOptions = row.options for (cell in row) { - var selection by remember(cell) { mutableStateOf(cell.selection) } - val options = remember(cell) { - allOptions.map { Toggleable(it, cell.options.contains(it)) } - } - LaunchedEffect(cell) { - cell.optionsChangedListeners.add { enabled -> - options.forEach { it.enabled = enabled.contains(it.item) } - } - cell.selectionChangedListeners.add { - selection = it - onUpdate() - } - } - Selector( - modifier = Modifier - .padding(spacing) - .weight(1f), + PuzzleCell( + cell, + row, + onUpdate, + onSnapshot, + onUndo, spacing, - selectDirectly = selectDirectly, - options = options, - onOptionRemoved = { - onSnapshot() - cell.options.remove(it) - row.cleanupOptions() - }, - onOptionAdded = { - cell.options.add(it) - }, - selectedItem = selection, - onSelectItem = { selectedItem -> - onSelectItem(row, cell, options, selectedItem, onSnapshot, onUndo) - } + selectionManager ) } } } +@Composable +private fun RowScope.PuzzleCell( + cell: GameCell>, + row: GameRow>, + onUpdate: () -> Unit, + onSnapshot: () -> Unit, + onUndo: () -> Boolean, + spacing: Dp, + selectionManager: GridSelectionManager +) { + var selection by remember(cell) { mutableStateOf(cell.selection) } + val options = remember(cell) { + row.options.map { Toggleable(it, cell.options.contains(it)) } + } + LaunchedEffect(cell) { + cell.optionsChangedListeners.add { enabled -> + options.forEach { it.enabled = enabled.contains(it.item) } + } + cell.selectionChangedListeners.add { + selection = it + onUpdate() + } + } + val focusable = remember(cell, selectionManager) { + selectionManager.add { e -> + if (selection != null) { + handleClearSelection(e.key, row, cell, options, onSnapshot, onUndo) + } else { + handleSelection(e, options, cell, row, onSnapshot, onUndo) + } + } + } + Selector( + modifier = Modifier + .focus(focusable) + .padding(spacing) + .weight(1f) + .testTag(focusable.position), + spacing, + options = options, + onOptionRemoved = { selectedItem -> + onOptionRemoved(row, cell, selectedItem, onSnapshot) + }, + onOptionAdded = { + cell.options.add(it) + }, + selectedItem = selection, + onSelectItem = { selectedItem -> + onSelectItem(row, cell, options, selectedItem, onSnapshot, onUndo) + } + ) +} + +private fun handleSelection( + e: KeyEvent, + options: List>>>, + cell: GameCell>, + row: GameRow>, + onSnapshot: () -> Unit, + onUndo: () -> Boolean +): Boolean { + val i = getNumber(e) + return if (i != null && i in 1..options.size) { + val selectedItem = options[i - 1].item + if (e.isShiftPressed) { + if (cell.options.contains(selectedItem)) { + onOptionRemoved(row, cell, selectedItem, onSnapshot) + } else { + cell.options.add(selectedItem) + } + } else { + onSelectItem(row, cell, options, selectedItem, onSnapshot, onUndo) + } + true + } else { + false + } +} + +private fun handleClearSelection( + key: Key, + row: GameRow>, + cell: GameCell>, + options: List>>>, + onSnapshot: () -> Unit, + onUndo: () -> Boolean +): Boolean = when (key) { + Key.Spacebar, Key.Enter, Key.Delete, Key.Backspace -> { + onSelectItem(row, cell, options, null, onSnapshot, onUndo) + true + } + + else -> false +} + +private fun onOptionRemoved( + row: GameRow>, + cell: GameCell>, + selectedItem: Item>?, + onSnapshot: () -> Unit +) { + onSnapshot() + cell.options.remove(selectedItem) + row.cleanupOptions() +} + +private fun getNumber(e: KeyEvent): Int? = when (e.key) { + Key.Zero, Key.NumPad0 -> 0 + Key.One, Key.NumPad1 -> 1 + Key.Two, Key.NumPad2 -> 2 + Key.Three, Key.NumPad3 -> 3 + Key.Four, Key.NumPad4 -> 4 + Key.Five, Key.NumPad5 -> 5 + Key.Six, Key.NumPad6 -> 6 + Key.Seven, Key.NumPad7 -> 7 + Key.Eight, Key.NumPad8 -> 8 + Key.Nine, Key.NumPad9 -> 9 + else -> null +} + private fun onSelectItem( row: GameRow>, cell: GameCell>, diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/AspectRatio.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/AspectRatio.kt new file mode 100644 index 0000000..2704451 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/AspectRatio.kt @@ -0,0 +1,21 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.ui.unit.Constraints + +enum class AspectRatio { + PORTRAIT, LANDSCAPE, SQUARISH; + + companion object { + private const val ASPECT_RATIO_LANDSCAPE = 1.4f + private const val ASPECT_RATIO_PORTRAIT = 1 / ASPECT_RATIO_LANDSCAPE + + fun from(constraints: Constraints): AspectRatio { + val ratio = constraints.maxWidth.toFloat() / constraints.maxHeight.toFloat() + return when { + ratio < ASPECT_RATIO_PORTRAIT -> PORTRAIT + ratio > ASPECT_RATIO_LANDSCAPE -> LANDSCAPE + else -> SQUARISH + } + } + } +} \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/FocusGroup.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/FocusGroup.kt new file mode 100644 index 0000000..d96d392 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/FocusGroup.kt @@ -0,0 +1,17 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.ui.layout.Measurable +import ch.dissem.yaep.ui.common.focus.CluesSelectionManager + +data class FocusGroup( + val items: List, + val box: Measurable, + val selectionManger: CluesSelectionManager? = null +) { + val item: Measurable + get() = items.single() + + val hasItems: Boolean = items.isNotEmpty() + + val count = items.size +} diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/adaptive game layout.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/adaptive game layout.kt new file mode 100644 index 0000000..777ae07 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/adaptive game layout.kt @@ -0,0 +1,162 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Constraints.Companion.fixed +import androidx.compose.ui.unit.dp +import ch.dissem.yaep.ui.common.focus.CluesSelectionManager +import ch.dissem.yaep.ui.common.focus.FocusFollowingFocusable +import ch.dissem.yaep.ui.common.focus.GridSelectionManager +import ch.dissem.yaep.ui.common.focus.SelectionManager +import ch.dissem.yaep.ui.common.layout.AspectRatio.LANDSCAPE +import ch.dissem.yaep.ui.common.layout.AspectRatio.PORTRAIT +import ch.dissem.yaep.ui.common.layout.AspectRatio.SQUARISH +import kotlin.math.ceil +import kotlin.math.max + + +@Composable +fun AdaptiveGameLayout( + modifier: Modifier = Modifier, + selectionManager: SelectionManager, + grid: @Composable (GridSelectionManager) -> Unit, + horizontalClues: @Composable (SelectionManager<*>) -> Unit, + verticalClues: @Composable (SelectionManager<*>) -> Unit, + time: @Composable () -> Unit, + vararg resetBeacons: Any +) { + val gridFocusable = remember(*resetBeacons) { selectionManager.add() } + val gridSelectionManager = remember(*resetBeacons) { gridFocusable.create(GridSelectionManager()) } + + val horizontalCluesFocusable = remember(*resetBeacons) { selectionManager.add() } + val horizontalCluesSelectionManager = remember(*resetBeacons) { + horizontalCluesFocusable.create(CluesSelectionManager()) + } + + val verticalCluesFocusable = remember(*resetBeacons) { selectionManager.add() } + val verticalCluesSelectionManager = remember(*resetBeacons) { + verticalCluesFocusable.create(CluesSelectionManager()) + } + Layout( + contents = listOf( + { grid(gridSelectionManager) }, + { FocusBox(gridFocusable, requestFocus = true) }, + { horizontalClues(horizontalCluesSelectionManager) }, + { FocusBox(horizontalCluesFocusable) }, + { verticalClues(verticalCluesSelectionManager) }, + { FocusBox(verticalCluesFocusable) }, + time, + { + HorizontalDivider() + HorizontalDivider() + } + ), + modifier = modifier + ) { measurables, constraints -> + layout(width = constraints.maxWidth, height = constraints.maxHeight) { + val aspectRatio = AspectRatio.from(constraints) + + val gridGroup = FocusGroup( + items = measurables[0], + box = measurables[1][0], + ) + val horizontalCluesGroup = FocusGroup( + items = measurables[2], + box = measurables[3][0], + selectionManger = horizontalCluesSelectionManager + ) + val verticalCluesGroup = FocusGroup( + items = measurables[4], + box = measurables[5][0], + selectionManger = verticalCluesSelectionManager + ) + val timeMeasurable = measurables[6][0] + val dividerMeasurables = measurables[7] + + val spacingPx = 8.dp.roundToPx() + + when (aspectRatio) { + PORTRAIT -> { + portrait( + constraints, + spacingPx, + gridGroup, + horizontalCluesGroup, + verticalCluesGroup, + timeMeasurable, + dividerMeasurables + ) + } + + SQUARISH -> { + squarish( + constraints, + spacingPx, + gridGroup, + horizontalCluesGroup, + verticalCluesGroup, + timeMeasurable + ) + } + + LANDSCAPE -> { + landscape( + constraints, + spacingPx, + gridGroup, + horizontalCluesGroup, + verticalCluesGroup, + timeMeasurable, + dividerMeasurables[0] + ) + } + } + } + } +} + +internal fun cluesBoxConstraints( + width: Int, + itemConstraints: Constraints, + group: FocusGroup +): Constraints { + val columns = width / itemConstraints.maxWidth + group.selectionManger?.columns = columns + return fixed( + width, + itemConstraints.maxHeight * ceil(group.count.toFloat() / columns).toInt() + ) +} + +internal fun Placeable.PlacementScope.placeClues( + placeables: List, + offsetX: Int, + offsetY: Int, + maxWidth: Int +): Int { + if (placeables.isEmpty()) return offsetY + + val itemWidth = placeables.first().width + val itemHeight = placeables.first().height + val columns = max(1, maxWidth / itemWidth) + val spacing = if (columns == 1) 0 else (maxWidth - columns * itemWidth) / (columns - 1) + var currentX = offsetX + var currentY = offsetY + var i = 0 + for (placeable in placeables) { + placeable.place(currentX, currentY) + currentX += itemWidth + spacing + i++ + if (i % columns == 0 && i < placeables.size) { + currentX = offsetX + currentY += itemHeight + } + } + return currentY + itemHeight +} + diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/focus box.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/focus box.kt new file mode 100644 index 0000000..af8410b --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/focus box.kt @@ -0,0 +1,44 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.Color +import ch.dissem.yaep.ui.common.focus.FocusFollowingFocusable + +@Composable +fun FocusBox(focusable: FocusFollowingFocusable, requestFocus: Boolean = false) { + val hasFocus by focusable.hasFocus.collectAsState(false) + Box( + modifier = Modifier + .focusRequester(focusable.focusRequester) + .onFocusEvent { state -> + focusable.setFocus(state) + } + .onFocusChanged {} + .focusable() + .clip(MaterialTheme.shapes.small) + .background( + if (hasFocus) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + Color.Transparent + } + ), + ) + if (requestFocus) { + LaunchedEffect(Unit) { + focusable.focusRequester.requestFocus() + } + } +} \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/landscape.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/landscape.kt new file mode 100644 index 0000000..5721584 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/landscape.kt @@ -0,0 +1,99 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Constraints.Companion.fixed +import androidx.compose.ui.unit.Constraints.Companion.fixedWidth + +internal fun Placeable.PlacementScope.landscape( + constraints: Constraints, + spacingPx: Int, + grid: FocusGroup, + horizontalClues: FocusGroup, + verticalClues: FocusGroup, + timeMeasurable: Measurable, + dividerMeasurable: Measurable +) { + val gridSize = constraints.maxHeight + val gridItemSize = (gridSize - 12 * spacingPx) / 18 + val rightBarWidth = constraints.maxWidth - gridSize - 2 * spacingPx + + val gridConstraints = fixed(gridSize, gridSize) + val baseSpace = gridSize - 2 * spacingPx + val horizontalCluesConstraints = fixed( + width = 3 * gridItemSize + 2 * spacingPx, + height = gridItemSize + 2 * spacingPx + ) + val verticalCluesConstraints = fixed( + width = gridItemSize + 2 * spacingPx, + height = 3 * gridItemSize + 2 * spacingPx + ) + val timeConstraints = Constraints.fixedHeight(baseSpace / 10) + val dividerConstraints = fixedWidth(rightBarWidth - 2 * spacingPx) + + val gridPlaceable = grid.item.measure(gridConstraints) + val gridBoxPlaceable = grid.box.measure(gridConstraints) + val horizontalCluesPlaceables = horizontalClues.items.map { + it.measure(horizontalCluesConstraints) + } + val horizontalCluesBoxPlaceable = horizontalClues.box + .measure( + cluesBoxConstraints( + width = rightBarWidth, + itemConstraints = horizontalCluesConstraints, + group = horizontalClues + ) + ) + + val verticalCluesPlaceables = verticalClues.items.map { + it.measure(verticalCluesConstraints) + } + val verticalCluesBoxPlaceable = verticalClues.box + .measure( + cluesBoxConstraints( + width = rightBarWidth, + itemConstraints = verticalCluesConstraints, + group = verticalClues + ) + ) + + val timePlaceable = timeMeasurable.measure(timeConstraints) + val dividerPlaceable = dividerMeasurable.measure(dividerConstraints) + + // Position the grid + gridBoxPlaceable.place(0, 0) + gridPlaceable.place(0, 0) + + // Position the horizontal clues4 + val horizontalCluesOffsetX = gridSize + 2 * spacingPx + horizontalCluesBoxPlaceable.place(horizontalCluesOffsetX, 0) + val offsetY = placeClues( + placeables = horizontalCluesPlaceables, + offsetX = horizontalCluesOffsetX, + offsetY = 0, + maxWidth = rightBarWidth + ) + + if (verticalClues.hasItems) { + // Add divider in between + dividerPlaceable.place(gridSize + 3 * spacingPx, offsetY + spacingPx) + + // Position the vertical clues + val verticalCluesOffsetX = gridSize + 2 * spacingPx + val verticalCluesOffsetY = offsetY + spacingPx + dividerPlaceable.height + verticalCluesBoxPlaceable.place(verticalCluesOffsetX, verticalCluesOffsetY) + placeClues( + placeables = verticalCluesPlaceables, + offsetX = verticalCluesOffsetX, + offsetY = verticalCluesOffsetY, + maxWidth = rightBarWidth + ) + } + + // Position the time + timePlaceable.place( + x = constraints.maxWidth - timePlaceable.width - spacingPx, + y = constraints.maxHeight - timePlaceable.height + ) +} \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/portrait.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/portrait.kt new file mode 100644 index 0000000..1298e0c --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/portrait.kt @@ -0,0 +1,114 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Constraints.Companion.fixed +import androidx.compose.ui.unit.Constraints.Companion.fixedWidth + +internal fun Placeable.PlacementScope.portrait( + constraints: Constraints, + spacingPx: Int, + grid: FocusGroup, + horizontalClues: FocusGroup, + verticalClues: FocusGroup, + timeMeasurable: Measurable, + dividerMeasurables: List +) { + val gridSize = constraints.maxWidth + val gridItemSize = (gridSize - 12 * spacingPx) / 18 + + val gridConstraints = fixed(gridSize, gridSize) + val horizontalCluesConstraints = fixed( + width = 3 * gridItemSize + 2 * spacingPx, + height = gridItemSize + 2 * spacingPx + ) + val verticalCluesConstraints = fixed( + width = gridItemSize + 2 * spacingPx, + height = 3 * gridItemSize + 2 * spacingPx + ) + val timeConstraints = Constraints() + val dividerConstraints = fixedWidth(gridSize) + + val gridPlaceable = grid.item.measure(gridConstraints) + val gridBoxPlaceable = grid.box.measure(gridConstraints) + val horizontalCluesPlaceables = horizontalClues.items.map { + it.measure(horizontalCluesConstraints) + } + val verticalCluesPlaceables = verticalClues.items.map { + it.measure(verticalCluesConstraints) + } + val timePlaceable = timeMeasurable.measure(timeConstraints) + val divider1Placeable = dividerMeasurables[0].measure(dividerConstraints) + val divider2Placeable = dividerMeasurables[1].measure(dividerConstraints) + + val horizontalCluesBoxPlaceable = horizontalClues.box + .measure( + cluesBoxConstraints( + width = gridSize, + itemConstraints = horizontalCluesConstraints, + group = horizontalClues + ) + ) + val verticalCluesBoxPlaceable = verticalClues.box + .measure( + cluesBoxConstraints( + width = gridSize, + itemConstraints = verticalCluesConstraints, + group = verticalClues + ) + ) + + // Position the grid + gridBoxPlaceable.place(0, 0) + gridPlaceable.place(0, 0) + + divider1Placeable.place(0, gridSize + spacingPx) + + // Position the horizontal clues + val horizontalCluesOffsetY = gridSize + 2 * spacingPx + + horizontalCluesBoxPlaceable.place(0, horizontalCluesOffsetY) + var offsetY = placeClues( + placeables = horizontalCluesPlaceables, + offsetX = 0, + offsetY = horizontalCluesOffsetY, + maxWidth = gridSize + ) + + if (verticalClues.hasItems) { + // Add divider in between + divider2Placeable.place(0, offsetY + spacingPx) + + // Position the vertical clues + val verticalCluesOffsetY = offsetY + spacingPx + divider2Placeable.height + verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY) + offsetY = placeClues( + placeables = verticalCluesPlaceables, + offsetX = 0, + offsetY = verticalCluesOffsetY, + maxWidth = gridSize + ) + } + // Position the time + val remainingSpace = constraints.maxHeight - offsetY + if (remainingSpace < timePlaceable.height) { + val scale = remainingSpace.toFloat() / timePlaceable.height.toFloat() + if (scale > 0.1f) { + timePlaceable.placeWithLayer( + x = constraints.maxWidth - timePlaceable.width - spacingPx, + y = constraints.maxHeight - timePlaceable.height + ) { + scaleX = scale + scaleY = scale + translationX = (timePlaceable.width * (1 - scale)) / 2f + translationY = (timePlaceable.height * (1 - scale)) / 2f + } + } + } else { + timePlaceable.place( + x = constraints.maxWidth - timePlaceable.width - spacingPx, + y = constraints.maxHeight - timePlaceable.height + ) + } +} diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/squarish.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/squarish.kt new file mode 100644 index 0000000..ec11722 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/squarish.kt @@ -0,0 +1,91 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Constraints.Companion.fixed +import kotlin.math.min + +internal fun Placeable.PlacementScope.squarish( + constraints: Constraints, + spacingPx: Int, + grid: FocusGroup, + horizontalClues: FocusGroup, + verticalClues: FocusGroup, + timeMeasurable: Measurable +) { + val gridSize = (7 * min(constraints.maxWidth, constraints.maxHeight)) / 10 + val gridItemSize = (gridSize - 12 * spacingPx) / 18 + val rightBarWidth = constraints.maxWidth - gridSize - spacingPx + + val gridConstraints = fixed(gridSize, gridSize) + val horizontalCluesConstraints = fixed( + width = 3 * gridItemSize + 2 * spacingPx, + height = gridItemSize + 2 * spacingPx + ) + val verticalCluesConstraints = fixed( + width = gridItemSize + 2 * spacingPx, + height = 3 * gridItemSize + 2 * spacingPx + ) + val timeConstraints = Constraints() + + + val gridPlaceable = grid.item.measure(gridConstraints) + val gridBoxPlaceable = grid.box.measure(gridConstraints) + val horizontalCluesPlaceables = horizontalClues.items.map { + it.measure(horizontalCluesConstraints) + } + val horizontalCluesBoxPlaceable = horizontalClues.box + .measure( + cluesBoxConstraints( + width = rightBarWidth, + itemConstraints = horizontalCluesConstraints, + group = horizontalClues + ) + ) + + val verticalCluesPlaceables = verticalClues.items.map { + it.measure(verticalCluesConstraints) + } + val verticalCluesBoxPlaceable = verticalClues.box + .measure( + cluesBoxConstraints( + width = rightBarWidth, + itemConstraints = verticalCluesConstraints, + group = verticalClues + ) + ) + val timePlaceable = timeMeasurable.measure(timeConstraints) + + // Position the grid + gridBoxPlaceable.place(0, 0) + gridPlaceable.place(0, 0) + + // Position the horizontal clues + val horizontalCluesOffsetX = gridSize + 2 * spacingPx + horizontalCluesBoxPlaceable.place(horizontalCluesOffsetX, 0) + placeClues( + placeables = horizontalCluesPlaceables, + offsetX = horizontalCluesOffsetX, + offsetY = 0, + maxWidth = rightBarWidth + ) + + if (verticalClues.hasItems) { + // Position the vertical clues + val verticalCluesOffsetY = gridSize + 2 * spacingPx + verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY) + placeClues( + placeables = verticalCluesPlaceables, + offsetX = 0, + offsetY = verticalCluesOffsetY, + maxWidth = gridSize + ) + } + + // Position the time + timePlaceable.place( + x = constraints.maxWidth - timePlaceable.width - spacingPx, + y = constraints.maxHeight - timePlaceable.height + ) +} diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/math.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/math.kt new file mode 100644 index 0000000..889d2c7 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/math.kt @@ -0,0 +1,8 @@ +package ch.dissem.yaep.ui.common + +import kotlin.math.absoluteValue +import kotlin.math.sign + +infix fun Int.ceilDiv(other: Int): Int { + return this.floorDiv(other) + this.rem(other).sign.absoluteValue +} diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/names.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/names.kt index 7a40b68..f52fcc0 100644 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/names.kt +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/names.kt @@ -13,16 +13,15 @@ import yaep.commonui.generated.resources.Res import yaep.commonui.generated.resources.ant import yaep.commonui.generated.resources.astronaut import yaep.commonui.generated.resources.banana -import yaep.commonui.generated.resources.bubble_tea import yaep.commonui.generated.resources.beverage import yaep.commonui.generated.resources.bicycle import yaep.commonui.generated.resources.british +import yaep.commonui.generated.resources.bubble_tea import yaep.commonui.generated.resources.bus import yaep.commonui.generated.resources.cake import yaep.commonui.generated.resources.canadian import yaep.commonui.generated.resources.cherries import yaep.commonui.generated.resources.chocolate -import yaep.commonui.generated.resources.water import yaep.commonui.generated.resources.coffee import yaep.commonui.generated.resources.cookie import yaep.commonui.generated.resources.cupcake @@ -41,6 +40,7 @@ import yaep.commonui.generated.resources.lemon import yaep.commonui.generated.resources.locomotive import yaep.commonui.generated.resources.lollipop import yaep.commonui.generated.resources.mango +import yaep.commonui.generated.resources.mate import yaep.commonui.generated.resources.milk import yaep.commonui.generated.resources.motor_scooter import yaep.commonui.generated.resources.norwegian @@ -63,8 +63,8 @@ import yaep.commonui.generated.resources.tea import yaep.commonui.generated.resources.teacher import yaep.commonui.generated.resources.tram_car import yaep.commonui.generated.resources.ukrainian +import yaep.commonui.generated.resources.water import yaep.commonui.generated.resources.watermelon -import yaep.commonui.generated.resources.mate import yaep.commonui.generated.resources.zebra val ItemClass<*>.localName: StringResource diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/selector.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/selector.kt index 01a5c9e..534ee24 100644 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/selector.kt +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/selector.kt @@ -1,7 +1,6 @@ package ch.dissem.yaep.ui.common import androidx.compose.foundation.Canvas -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -20,6 +19,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import ch.dissem.yaep.domain.Item import ch.dissem.yaep.domain.ItemClass import ch.dissem.yaep.ui.common.theme.emojiFontFamily @@ -28,8 +28,7 @@ import kotlin.math.min @Composable fun > Selector( modifier: Modifier = Modifier, - spacing: Dp, - selectDirectly: Boolean, + spacing: Dp = 4.dp, options: List>>, onOptionRemoved: (Item) -> Unit, onOptionAdded: (Item) -> Unit, @@ -40,7 +39,7 @@ fun > Selector( if (selectedItem != null) { DrawItem( item = selectedItem, - modifier = modifier.clickable { onSelectItem(null) }, + modifier = modifier.onEitherPointerAction { onSelectItem(null) }, spacing = radius ) } else { @@ -83,8 +82,8 @@ fun > Selector( @Composable fun > DrawItem( modifier: Modifier = Modifier, - spacing: Dp, - item: Item + item: Item, + spacing: Dp = 4.dp ) { OutlinedCard(modifier = modifier.aspectRatio(1f), shape = RoundedCornerShape(spacing)) { val emoji = item.symbol diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/theme/Color.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/theme/Color.kt index 3e44904..93edae3 100644 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/theme/Color.kt +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/theme/Color.kt @@ -1,4 +1,5 @@ package ch.dissem.yaep.ui.common.theme + import androidx.compose.ui.graphics.Color val primaryLight = Color(0xFF6D5E0F) diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/widget status.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/widget status.kt new file mode 100644 index 0000000..8c15c57 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/widget status.kt @@ -0,0 +1,39 @@ +package ch.dissem.yaep.ui.common + +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import ch.dissem.yaep.ui.common.focus.FocusFollowingFocusable +import ch.dissem.yaep.ui.common.focus.Focusable + +@Composable +fun Modifier.focus(focusable: Focusable<*>): Modifier { + var m = this + val hasFocus by focusable.hasFocus.collectAsState(false) + if (hasFocus) { + m = m.border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = RectangleShape + ) + } + if (focusable is FocusFollowingFocusable) { + m = m + .focusRequester(focusable.focusRequester) + .onFocusEvent { state -> + focusable.setFocus(state) + } + .onFocusChanged {} + .focusable() + } + return m +} \ No newline at end of file diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/AppTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/AppTest.kt new file mode 100644 index 0000000..19d9bbc --- /dev/null +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/AppTest.kt @@ -0,0 +1,181 @@ +package ch.dissem.yaep.ui.common + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SkikoComposeUiTest +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.runSkikoComposeUiTest +import androidx.compose.ui.test.withKeyDown +import androidx.compose.ui.unit.dp +import ch.dissem.yaep.domain.Game +import ch.dissem.yaep.domain.UnsolvablePuzzleException +import ch.dissem.yaep.ui.common.focus.FocusFollowingSelectionManager +import ch.dissem.yaep.ui.common.focus.GridSelectionManager +import ch.tutteli.atrium.api.fluent.en_GB.notToEqualNull +import ch.tutteli.atrium.api.fluent.en_GB.toBeAnInstanceOf +import ch.tutteli.atrium.api.fluent.en_GB.toEqual +import ch.tutteli.atrium.api.fluent.en_GB.toHaveElementsAndAll +import ch.tutteli.atrium.api.fluent.en_GB.toHaveSize +import ch.tutteli.atrium.api.verbs.expect +import kotlin.test.Test +import kotlin.test.fail + +@OptIn(ExperimentalTestApi::class) +class AppTest { + val game = createGame() + var resetCluesBeacon by mutableStateOf(Any()) + + @Test + fun ensure_app_can_be_rendered_on_1920x1080_and_can_be_solved() = showApp( + Size(1920f, 1080f) + ) { + val clues = game.clues + val grid = game.grid + var removedOptions: Boolean + do { + removedOptions = false + try { + clues.forEach { clue -> + val optionsRemoved = clue.removeForbiddenOptions(grid) + removedOptions = optionsRemoved || removedOptions + if (optionsRemoved) { + resetCluesBeacon = Any() + } + } + } catch (_: UnsolvablePuzzleException) { + fail("puzzle can't be solved") + } + grid.cleanupOptions() + resetCluesBeacon = Any() + } while (removedOptions) + + expect(game.isSolved).toEqual(true) + onNodeWithTag("EndOfGame.solved_time") + .assertExists("End of Game must be shown when puzzle is solved") + } + + @Test + fun ensure_app_can_be_rendered_on_1080x1920_and_keyboard_selection_and_deselection_works() = showApp( + Size(1080f, 1920f) + ) { + expect(game.grid[0][1].selection).toEqual(null) + expect(FocusFollowingSelectionManager.focused).notToEqualNull() + expect(FocusFollowingSelectionManager.focused?.child).toBeAnInstanceOf() + onRoot().performKeyInput { + pressKey(Key.DirectionRight) + pressKey(Key.One) + } + expect(game.grid[0][1].selection).notToEqualNull() + + onRoot().performKeyInput { + pressKey(Key.Backspace) + } + expect(game.grid[0][1].selection).toEqual(null) + expect(game.grid.map { it.options }).toHaveElementsAndAll { + toHaveSize(6) + } + } + + @Test + fun ensure_app_can_be_rendered_on_800x600_and_keyboard_option_removal_works() = showApp( + Size(800f, 600f) + ) { + val gridCell = game.grid[0][1] + + expect(gridCell.selection).toEqual(null) + onRoot().performKeyInput { + pressKey(Key.DirectionRight) + withKeyDown(Key.ShiftLeft) { + expect(gridCell.options).toHaveSize(6) + pressKey(Key.One) + expect(gridCell.options).toHaveSize(5) + pressKey(Key.Two) + expect(gridCell.options).toHaveSize(4) + pressKey(Key.Three) + expect(gridCell.options).toHaveSize(3) + pressKey(Key.Four) + expect(gridCell.options).toHaveSize(2) + pressKey(Key.Five) + expect(gridCell.options).toHaveSize(1) + } + } + expect(gridCell.selection).notToEqualNull() + } + + @Test + fun ensure_app_can_be_rendered_on_600x800_and_numpad_option_selection_works() = showApp( + Size(600f, 800f) + ) { + val gridCell = game.grid[0][1] + + onRoot().performKeyInput { + pressKey(Key.DirectionRight) + withKeyDown(Key.ShiftLeft) { + expect(gridCell.options).toHaveSize(6) + pressKey(Key.NumPad1) + expect(gridCell.options).toHaveSize(5) + pressKey(Key.NumPad5) + expect(gridCell.options).toHaveSize(4) + pressKey(Key.NumPad5) + expect(gridCell.options).toHaveSize(5) + pressKey(Key.NumPad4) + expect(gridCell.options).toHaveSize(4) + } + } + } + + fun showApp( + screenSize: Size, + block: suspend SkikoComposeUiTest.() -> Unit = {} + ) = runSkikoComposeUiTest(size = screenSize) { + setContent { + App( + modifier = Modifier.onKeyEvent { event -> + FocusFollowingSelectionManager.onKeyEvent(event) + }, + rootSelectionManager = FocusFollowingSelectionManager, + spacing = 8.dp, + game = game, + onNewGame = { }, + resetCluesBeacon = resetCluesBeacon + ) + } + block() + } + + private fun createGame(): Game = Game.parse( + """ + * MATE is between the neighbours TEACHER and GOAT to both sides + * SCIENTIST is between the neighbours SPAIN and FIREFIGHTER to both sides + * TEA is left of COFFEE + * ZEBRA is between the neighbours LEMON and CAKE to both sides + * CUPCAKE is left of GOAT + * ROCK_STAR is left of SPAIN + * PEAR is at position 1 + * CHOCOLATE is between the neighbours TEA and MILK to both sides + * FARMER is between the neighbours PEAR and CHERRIES to both sides + * SWITZERLAND is between the neighbours JAPAN and DOG to both sides + * MANGO and SPAIN are in the same column + * SPAIN is next to SNAIL + * CANADA is next to PIE + * JAPAN is between the neighbours SWITZERLAND and CAKE to both sides + * FIREFIGHTER is between the neighbours GOAT and STRAWBERRY to both sides + * UKRAINE is between the neighbours ANT and BEVERAGE to both sides + * CHOCOLATE and SWITZERLAND are in the same column + * PEAR is left of COFFEE + * JAPAN is between the neighbours CAKE and COFFEE to both sides + * BEVERAGE is between the neighbours CUSTARD and UKRAINE to both sides + * CANADA and CAKE are in the same column + """.trimIndent() + ) + +} \ No newline at end of file diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/NamesTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/NamesTest.kt new file mode 100644 index 0000000..d8aacc3 --- /dev/null +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/NamesTest.kt @@ -0,0 +1,66 @@ +package ch.dissem.yaep.ui.common + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import ch.dissem.yaep.domain.Animal +import ch.dissem.yaep.domain.Drink +import ch.dissem.yaep.domain.Fruit +import ch.dissem.yaep.domain.ItemClass +import ch.dissem.yaep.domain.Nationality +import ch.dissem.yaep.domain.Profession +import ch.dissem.yaep.domain.Transportation +import ch.tutteli.atrium.api.fluent.en_GB.notToBeBlank +import ch.tutteli.atrium.api.fluent.en_GB.toHaveElementsAndAll +import ch.tutteli.atrium.api.verbs.expect +import org.jetbrains.compose.resources.stringResource +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class NamesTest { + @Test + fun ensure_names_are_defined_for_animal() { + ensure_names_are_defined_for(Animal.items) + } + + @Test + fun ensure_names_are_defined_for_nationality() { + ensure_names_are_defined_for(Nationality.items) + } + + @Test + fun ensure_names_are_defined_for_drink() { + ensure_names_are_defined_for(Drink.items) + } + + @Test + fun ensure_names_are_defined_for_profession() { + ensure_names_are_defined_for(Profession.items) + } + + @Test + fun ensure_names_are_defined_for_fruit() { + ensure_names_are_defined_for(Fruit.items) + } + + @Test + fun ensure_names_are_defined_for_dessert() { + ensure_names_are_defined_for(Fruit.items) + } + + @Test + fun ensure_names_are_defined_for_transportation() { + ensure_names_are_defined_for(Transportation.items) + } + + fun ensure_names_are_defined_for(items: List>) { + runComposeUiTest { + var strings = listOf() + setContent { + strings = items.map { stringResource(it.localName) } + } + expect(strings).toHaveElementsAndAll { + notToBeBlank() + } + } + } +} \ No newline at end of file diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManagerTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManagerTest.kt new file mode 100644 index 0000000..c0923f2 --- /dev/null +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManagerTest.kt @@ -0,0 +1,96 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.input.key.Key +import ch.tutteli.atrium.api.fluent.en_GB.toEqual +import ch.tutteli.atrium.api.verbs.expect +import kotlin.test.Test + +class CluesSelectionManagerTest : SelectionManagerTest() { + + var primaryActionCalled: Int? = null + var secondaryActionCalled: Int? = null + + lateinit var focusables: MutableList + + override fun setUp() { + manager = CluesSelectionManager() + + primaryActionCalled = null + secondaryActionCalled = null + focusables = mutableListOf() + for (i in 0..10) { + focusables.add( + manager.add( + primaryAction = { + primaryActionCalled = i + }, + secondaryAction = { + secondaryActionCalled = i + } + ) + ) + } + manager.columns = 3 + } + + @Test + fun ensure_navigation_right_cycles() { + manager.onKeyEvent(keyEvent(Key.DirectionRight)) + expect(manager.focused).toEqual(focusables[1]) + + manager.onKeyEvent(keyEvent(Key.DirectionRight)) + expect(manager.focused).toEqual(focusables[2]) + + manager.onKeyEvent(keyEvent(Key.DirectionRight)) + expect(manager.focused).toEqual(focusables[0]) + } + + @Test + fun ensure_navigation_left_cycles() { + manager.onKeyEvent(keyEvent(Key.DirectionLeft)) + expect(manager.focused).toEqual(focusables[2]) + + manager.onKeyEvent(keyEvent(Key.DirectionLeft)) + expect(manager.focused).toEqual(focusables[1]) + + manager.onKeyEvent(keyEvent(Key.DirectionLeft)) + expect(manager.focused).toEqual(focusables[0]) + + manager.onKeyEvent(keyEvent(Key.DirectionLeft)) + expect(manager.focused).toEqual(focusables[2]) + } + + @Test + fun ensure_navigation_down_cycles() { + manager.onKeyEvent(keyEvent(Key.DirectionDown)) + expect(manager.focused).toEqual(focusables[3]) + + manager.onKeyEvent(keyEvent(Key.DirectionDown)) + expect(manager.focused).toEqual(focusables[6]) + + manager.onKeyEvent(keyEvent(Key.DirectionDown)) + expect(manager.focused).toEqual(focusables[9]) + + manager.onKeyEvent(keyEvent(Key.DirectionDown)) + expect(manager.focused).toEqual(focusables[0]) + } + + @Test + fun ensure_navigation_up_cycles() { + manager.onKeyEvent(keyEvent(Key.DirectionUp)) + expect(manager.focused).toEqual(focusables[9]) + + manager.onKeyEvent(keyEvent(Key.DirectionUp)) + expect(manager.focused).toEqual(focusables[6]) + + manager.onKeyEvent(keyEvent(Key.DirectionUp)) + expect(manager.focused).toEqual(focusables[3]) + + manager.onKeyEvent(keyEvent(Key.DirectionUp)) + expect(manager.focused).toEqual(focusables[0]) + + manager.onKeyEvent(keyEvent(Key.DirectionUp)) + expect(manager.focused).toEqual(focusables[9]) + } + +} \ No newline at end of file diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManagerTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManagerTest.kt new file mode 100644 index 0000000..54ff7a0 --- /dev/null +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManagerTest.kt @@ -0,0 +1,67 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.focus.FocusState +import ch.tutteli.atrium.api.fluent.en_GB.notToEqual +import ch.tutteli.atrium.api.fluent.en_GB.toEqual +import ch.tutteli.atrium.api.verbs.expect +import kotlin.test.Test + +class FocusFollowingSelectionManagerTest : + SelectionManagerTest() { + + var focusables = mutableListOf() + + val active = object : FocusState { + override val isFocused: Boolean = true + override val hasFocus: Boolean = true + override val isCaptured: Boolean = true + } + val inactive = object : FocusState { + override val isFocused: Boolean = false + override val hasFocus: Boolean = false + override val isCaptured: Boolean = false + } + + override fun setUp() { + manager = FocusFollowingSelectionManager + manager.focused = null + + focusables = mutableListOf() + repeat(4) { + focusables.add(manager.add()) + } + } + + @Test + fun ensure_focus_is_followed() { + expect(manager.focused).toEqual(null) + for (focusable in focusables) { + expect(manager.focused).notToEqual(focusable) + focusable.setFocus(active) + expect(manager.focused).toEqual(focusable) + } + } + + @Test + fun ensure_focus_is_lost_when_same_focusable_is_set_inactive() { + for (focusable in focusables) { + focusable.setFocus(active) + expect(manager.focused).toEqual(focusable) + focusable.setFocus(inactive) + expect(manager.focused).toEqual(null) + } + } + + @Test + fun ensure_focus_is_not_lost_if_different_focusable_is_set_inactive() { + var lastFocused: FocusFollowingFocusable? = null + for (focusable in focusables) { + focusable.setFocus(inactive) + expect(manager.focused).toEqual(lastFocused) + focusable.setFocus(active) + expect(manager.focused).toEqual(focusable) + lastFocused = focusable + } + expect(manager.focused).toEqual(lastFocused) + } +} \ No newline at end of file diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManagerTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManagerTest.kt new file mode 100644 index 0000000..af4077e --- /dev/null +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManagerTest.kt @@ -0,0 +1,80 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.input.key.Key +import ch.tutteli.atrium.api.fluent.en_GB.toEqual +import ch.tutteli.atrium.api.verbs.expect +import kotlin.test.Test + +class GridSelectionManagerTest : SelectionManagerTest() { + + lateinit var focusables: MutableList + + override fun setUp() { + manager = GridSelectionManager() + + focusables = mutableListOf() + repeat(3) { + manager.addRow() + repeat(3) { + focusables.add( + manager.add() + ) + } + } + } + + @Test + fun ensure_navigation_right_cycles() { + manager.onKeyEvent(keyEvent(Key.DirectionRight)) + expect(manager.focused).toEqual(focusables[1]) + + manager.onKeyEvent(keyEvent(Key.DirectionRight)) + expect(manager.focused).toEqual(focusables[2]) + + manager.onKeyEvent(keyEvent(Key.DirectionRight)) + expect(manager.focused).toEqual(focusables[0]) + } + + @Test + fun ensure_navigation_left_cycles() { + manager.onKeyEvent(keyEvent(Key.DirectionLeft)) + expect(manager.focused).toEqual(focusables[2]) + + manager.onKeyEvent(keyEvent(Key.DirectionLeft)) + expect(manager.focused).toEqual(focusables[1]) + + manager.onKeyEvent(keyEvent(Key.DirectionLeft)) + expect(manager.focused).toEqual(focusables[0]) + + manager.onKeyEvent(keyEvent(Key.DirectionLeft)) + expect(manager.focused).toEqual(focusables[2]) + } + + @Test + fun ensure_navigation_down_cycles() { + manager.onKeyEvent(keyEvent(Key.DirectionDown)) + expect(manager.focused).toEqual(focusables[3]) + + manager.onKeyEvent(keyEvent(Key.DirectionDown)) + expect(manager.focused).toEqual(focusables[6]) + + manager.onKeyEvent(keyEvent(Key.DirectionDown)) + expect(manager.focused).toEqual(focusables[0]) + } + + @Test + fun ensure_navigation_up_cycles() { + manager.onKeyEvent(keyEvent(Key.DirectionUp)) + expect(manager.focused).toEqual(focusables[6]) + + manager.onKeyEvent(keyEvent(Key.DirectionUp)) + expect(manager.focused).toEqual(focusables[3]) + + manager.onKeyEvent(keyEvent(Key.DirectionUp)) + expect(manager.focused).toEqual(focusables[0]) + + manager.onKeyEvent(keyEvent(Key.DirectionUp)) + expect(manager.focused).toEqual(focusables[6]) + } + +} \ No newline at end of file diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManagerTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManagerTest.kt new file mode 100644 index 0000000..5e5f611 --- /dev/null +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManagerTest.kt @@ -0,0 +1,73 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.input.key.Key +import ch.tutteli.atrium.api.fluent.en_GB.toEqual +import ch.tutteli.atrium.api.verbs.expect +import kotlin.test.Test + +class LinearSelectionManagerTest : SelectionManagerTest() { + + override fun setUp() { + manager = LinearSelectionManager( + keyNext = Key.A, + keyPrevious = Key.B + ) + } + + @Test + fun ensure_manager_with_focusables_has_focus_on_first_item() { + val focusables = arrayOf( + manager.add(), + manager.add() + ) + + expect(manager.focused).toEqual(focusables[0]) + } + + @Test + fun ensure_keyEvent_A_selects_next() { + val focusables = arrayOf( + manager.add(), + manager.add(), + manager.add() + ) + + manager.onKeyEvent(keyEvent(Key.A)) + expect(manager.focused).toEqual(focusables[1]) + + manager.onKeyEvent(keyEvent(Key.A)) + expect(manager.focused).toEqual(focusables[2]) + } + + @Test + fun ensure_keyEvent_B_selects_previous() { + val focusables = arrayOf( + manager.add(), + manager.add(), + manager.add() + ) + manager.focused = focusables[2] + + manager.onKeyEvent(keyEvent(Key.B)) + expect(manager.focused).toEqual(focusables[1]) + + manager.onKeyEvent(keyEvent(Key.B)) + expect(manager.focused).toEqual(focusables[0]) + } + + @Test + fun ensure_ring_behaviour() { + val focusables = arrayOf( + manager.add(), + manager.add(), + manager.add() + ) + + manager.onKeyEvent(keyEvent(Key.B)) + expect(manager.focused).toEqual(focusables[2]) + + manager.onKeyEvent(keyEvent(Key.A)) + expect(manager.focused).toEqual(focusables[0]) + } + +} \ No newline at end of file diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManagerTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManagerTest.kt new file mode 100644 index 0000000..e04a9b4 --- /dev/null +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManagerTest.kt @@ -0,0 +1,59 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import ch.tutteli.atrium.api.fluent.en_GB.toEqual +import ch.tutteli.atrium.api.verbs.expect +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.fail + +abstract class SelectionManagerTest, M : SelectionManager> { + lateinit var manager: M + + @BeforeTest + abstract fun setUp() + + @Test + fun ensure_key_event_is_ignored_if_not_keyup() { + for (t in arrayOf(KeyEventType.KeyDown, KeyEventType.Unknown)) { + manager.focused = manager.add { + fail("Action must not be called, event type $t should be ignored") + } + manager.onKeyEvent(keyEvent(Key.Enter, t)) + } + } + + @Test + fun ensure_primary_action_is_called() { + for (k in arrayOf(Key.Enter, Key.Spacebar)) { + var primary = false + var secondary = false + + manager.focused = manager.add( + primaryAction = { primary = true }, + secondaryAction = { secondary = true } + ) + manager.onKeyEvent(keyEvent(k)) + expect(primary).toEqual(true) + expect(secondary).toEqual(false) + } + } + + @Test + fun ensure_secondary_action_is_called() { + for (k in arrayOf(Key.Backspace, Key.Delete)) { + var primary = false + var secondary = false + + manager.focused = manager.add( + primaryAction = { primary = true }, + secondaryAction = { secondary = true } + ) + manager.onKeyEvent(keyEvent(k)) + expect(primary).toEqual(false) + expect(secondary).toEqual(true) + } + } + +} \ No newline at end of file diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/keybord utils.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/keybord utils.kt new file mode 100644 index 0000000..ecaff3d --- /dev/null +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/keybord utils.kt @@ -0,0 +1,12 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType + +@OptIn(InternalComposeUiApi::class) +fun keyEvent(key: Key, type: KeyEventType = KeyEventType.KeyUp) = KeyEvent( + key = key, + type = type +) \ No newline at end of file diff --git a/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/desktop window.kt b/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/desktop window.kt index 15956a0..084bd3e 100644 --- a/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/desktop window.kt +++ b/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/desktop window.kt @@ -16,10 +16,12 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowScope import androidx.compose.ui.window.WindowState +import ch.dissem.yaep.ui.common.focus.FocusFollowingSelectionManager import ch.dissem.yaep.ui.common.theme.AppTheme import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -40,11 +42,13 @@ import yaep.desktop.generated.resources.Res as DRes @Composable fun WindowScope.DesktopWindow( useDarkMode: Boolean, + selectionManager: FocusFollowingSelectionManager, topBar: @Composable () -> Unit, content: @Composable (PaddingValues) -> Unit ) { AppTheme(darkTheme = useDarkMode) { Scaffold( + modifier = Modifier.onKeyEvent { event -> selectionManager.onKeyEvent(event) }, topBar = { WindowDraggableArea { topBar() diff --git a/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/main.kt b/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/main.kt index 0c2b3c9..164a997 100644 --- a/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/main.kt +++ b/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/main.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import ch.dissem.yaep.domain.generateGame import ch.dissem.yaep.ui.common.App +import ch.dissem.yaep.ui.common.focus.FocusFollowingSelectionManager import ch.dissem.yaep.ui.common.theme.emojiFontFamily import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -47,10 +48,12 @@ fun main(): Unit = application { state = windowState, icon = painterResource(DRes.drawable.ic_launcher) ) { + val rootSelectionManager = FocusFollowingSelectionManager var useDarkMode by remember { mutableStateOf(true) } var resetCluesBeacon by remember { mutableStateOf(Any()) } DesktopWindow( useDarkMode = useDarkMode, + selectionManager = rootSelectionManager, topBar = { AppBar( useDarkMode = useDarkMode, @@ -67,8 +70,8 @@ fun main(): Unit = application { ) { App( modifier = Modifier.padding(it), + rootSelectionManager = rootSelectionManager, spacing = 8.dp, - selectDirectly = true, game = game, onNewGame = { game = generateGame() }, resetCluesBeacon = resetCluesBeacon diff --git a/domain/src/commonMain/kotlin/ch/dissem/yaep/domain/GameRow.kt b/domain/src/commonMain/kotlin/ch/dissem/yaep/domain/GameRow.kt index b87e9c7..1ecfc55 100644 --- a/domain/src/commonMain/kotlin/ch/dissem/yaep/domain/GameRow.kt +++ b/domain/src/commonMain/kotlin/ch/dissem/yaep/domain/GameRow.kt @@ -1,6 +1,5 @@ package ch.dissem.yaep.domain -@Suppress("JavaDefaultMethodsNotOverriddenByDelegation") class GameRow>( val category: ItemClassCompanion, val options: List>, @@ -22,12 +21,8 @@ class GameRow>( fun indexOf(element: C): Int = indexOfFirst { it.selection?.itemType == element } fun cleanupOptions() { - if (isSolved && all { - it.solution != null - && it.options.size == 1 - && it.options.single() == it.selection - } - ) return + if (isSolved && all { it.options.size == 1 && it.options.single() == it.selection }) + return do { var changed = false val selections = filter { it.selection != null }.let { cellsWithSelection -> @@ -58,11 +53,7 @@ class GameRow>( } } } while (changed) - isSolved = all { - it.solution != null - && it.options.size == 1 - && it.options.single() == it.selection - } + isSolved = all { it.options.size == 1 && it.options.single() == it.selection } } fun snapshot() { @@ -72,11 +63,7 @@ class GameRow>( fun undo(): Boolean { val didChange = cells.map { it.undo() }.reduce { a, b -> a || b } if (didChange) { - isSolved = all { - it.solution != null - && it.options.size == 1 - && it.options.single() == it.selection - } + isSolved = all { it.options.size == 1 && it.options.single() == it.selection } } return didChange }