Add Keyboard Control

This commit is contained in:
2025-06-02 06:06:11 +02:00
parent 54b1810606
commit 93103e4e49
38 changed files with 1824 additions and 418 deletions

View File

@@ -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
)

View File

@@ -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<FocusFollowingFocusable>,
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 = {
verticalClues = { selectionManager ->
if (verticalClues.isNotEmpty()) {
for (clue in verticalClues) {
VerticalClue(
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
)
}
}
},
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)
}

View File

@@ -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<Measurable>,
verticalCluesMeasurables: List<Measurable>,
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<Measurable>,
verticalCluesMeasurables: List<Measurable>,
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<Measurable>,
verticalCluesMeasurables: List<Measurable>,
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<Placeable>,
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
}

View File

@@ -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
) {

View File

@@ -0,0 +1,7 @@
package ch.dissem.yaep.ui.common.focus
class CluesFocusable(
manager: CluesSelectionManager,
primaryAction: (() -> Unit)?,
secondaryAction: (() -> Unit)?
) : Focusable<CluesFocusable>(manager, primaryAction, secondaryAction)

View File

@@ -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<CluesFocusable>() {
var columns: Int = 1
private var row: Int = 0
private var col: Int = 0
private val focusables = mutableListOf<CluesFocusable>()
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
}
}

View File

@@ -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<FocusFollowingFocusable>(manager, primaryAction, secondaryAction) {
val focusRequester = FocusRequester()
fun setFocus(state: FocusState) {
if (state.hasFocus) {
manager.focused = this
} else if (manager.focused === this) {
manager.focused = null
}
}
}

View File

@@ -0,0 +1,24 @@
package ch.dissem.yaep.ui.common.focus
import androidx.compose.ui.input.key.KeyEvent
object FocusFollowingSelectionManager : SelectionManager<FocusFollowingFocusable>() {
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
}

View File

@@ -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<F : Focusable<F>>(
protected val manager: SelectionManager<F>,
val primaryAction: (() -> Unit)?,
val secondaryAction: (() -> Unit)?,
var onKeyEvent: ((KeyEvent) -> Boolean)? = null
) {
val hasFocus: Flow<Boolean> =
combine(manager.isActiveFlow, manager.focusedFlow) { isActive, focused ->
isActive && focused == this
}
var child: SelectionManager<*>? = null
private set
fun <M : SelectionManager<*>> create(selectionManager: M): M = selectionManager.apply {
isActive = manager.isActive && manager.focused == this@Focusable
this@Focusable.child = this
}
}

View File

@@ -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<GridFocusable>(manager, primaryAction, secondaryAction, onKeyEvent)

View File

@@ -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<GridFocusable>() {
val grid = mutableListOf<MutableList<GridFocusable>>()
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
}
}

View File

@@ -0,0 +1,21 @@
package ch.dissem.yaep.ui.common.focus
class LinearFocusable(
manager: LinearSelectionManager,
primaryAction: (() -> Unit)?,
secondaryAction: (() -> Unit)?
) : Focusable<LinearFocusable>(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
}
}

View File

@@ -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<LinearFocusable>() {
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
}
}

View File

@@ -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<F : Focusable<F>> {
var isActiveFlow = MutableStateFlow<Boolean>(false)
var isActive: Boolean
get() = isActiveFlow.value
set(value) {
isActiveFlow.value = value
}
var focusedFlow = MutableStateFlow<F?>(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
}

View File

@@ -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,18 +56,40 @@ 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) {
PuzzleCell(
cell,
row,
onUpdate,
onSnapshot,
onUndo,
spacing,
selectionManager
)
}
}
}
@Composable
private fun RowScope.PuzzleCell(
cell: GameCell<ItemClass<*>>,
row: GameRow<ItemClass<*>>,
onUpdate: () -> Unit,
onSnapshot: () -> Unit,
onUndo: () -> Boolean,
spacing: Dp,
selectionManager: GridSelectionManager
) {
var selection by remember(cell) { mutableStateOf(cell.selection) }
val options = remember(cell) {
allOptions.map { Toggleable(it, cell.options.contains(it)) }
row.options.map { Toggleable(it, cell.options.contains(it)) }
}
LaunchedEffect(cell) {
cell.optionsChangedListeners.add { enabled ->
@@ -71,17 +100,25 @@ private fun PuzzleRow(
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),
.weight(1f)
.testTag(focusable.position),
spacing,
selectDirectly = selectDirectly,
options = options,
onOptionRemoved = {
onSnapshot()
cell.options.remove(it)
row.cleanupOptions()
onOptionRemoved = { selectedItem ->
onOptionRemoved(row, cell, selectedItem, onSnapshot)
},
onOptionAdded = {
cell.options.add(it)
@@ -92,7 +129,72 @@ private fun PuzzleRow(
}
)
}
private fun handleSelection(
e: KeyEvent,
options: List<Toggleable<Item<ItemClass<*>>>>,
cell: GameCell<ItemClass<*>>,
row: GameRow<ItemClass<*>>,
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<ItemClass<*>>,
cell: GameCell<ItemClass<*>>,
options: List<Toggleable<Item<ItemClass<*>>>>,
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<ItemClass<*>>,
cell: GameCell<ItemClass<*>>,
selectedItem: Item<ItemClass<*>>?,
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(

View File

@@ -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
}
}
}
}

View File

@@ -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<Measurable>,
val box: Measurable,
val selectionManger: CluesSelectionManager? = null
) {
val item: Measurable
get() = items.single()
val hasItems: Boolean = items.isNotEmpty()
val count = items.size
}

View File

@@ -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<FocusFollowingFocusable>,
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<Placeable>,
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
}

View File

@@ -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()
}
}
}

View File

@@ -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
)
}

View File

@@ -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<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 = 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
)
}
}

View File

@@ -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
)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 <C : ItemClass<C>> Selector(
modifier: Modifier = Modifier,
spacing: Dp,
selectDirectly: Boolean,
spacing: Dp = 4.dp,
options: List<Toggleable<Item<C>>>,
onOptionRemoved: (Item<C>) -> Unit,
onOptionAdded: (Item<C>) -> Unit,
@@ -40,7 +39,7 @@ fun <C : ItemClass<C>> 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 <C : ItemClass<C>> Selector(
@Composable
fun <C : ItemClass<C>> DrawItem(
modifier: Modifier = Modifier,
spacing: Dp,
item: Item<C>
item: Item<C>,
spacing: Dp = 4.dp
) {
OutlinedCard(modifier = modifier.aspectRatio(1f), shape = RoundedCornerShape(spacing)) {
val emoji = item.symbol

View File

@@ -1,4 +1,5 @@
package ch.dissem.yaep.ui.common.theme
import androidx.compose.ui.graphics.Color
val primaryLight = Color(0xFF6D5E0F)

View File

@@ -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
}

View File

@@ -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<GridSelectionManager>()
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()
)
}

View File

@@ -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<ItemClass<*>>) {
runComposeUiTest {
var strings = listOf<String>()
setContent {
strings = items.map { stringResource(it.localName) }
}
expect(strings).toHaveElementsAndAll {
notToBeBlank()
}
}
}
}

View File

@@ -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<CluesFocusable, CluesSelectionManager>() {
var primaryActionCalled: Int? = null
var secondaryActionCalled: Int? = null
lateinit var focusables: MutableList<CluesFocusable>
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])
}
}

View File

@@ -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<FocusFollowingFocusable, FocusFollowingSelectionManager>() {
var focusables = mutableListOf<FocusFollowingFocusable>()
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)
}
}

View File

@@ -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<GridFocusable, GridSelectionManager>() {
lateinit var focusables: MutableList<GridFocusable>
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])
}
}

View File

@@ -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<LinearFocusable, LinearSelectionManager>() {
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])
}
}

View File

@@ -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<F : Focusable<F>, M : SelectionManager<F>> {
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)
}
}
}

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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

View File

@@ -1,6 +1,5 @@
package ch.dissem.yaep.domain
@Suppress("JavaDefaultMethodsNotOverriddenByDelegation")
class GameRow<C : ItemClass<C>>(
val category: ItemClassCompanion<C>,
val options: List<Item<C>>,
@@ -22,12 +21,8 @@ class GameRow<C : ItemClass<C>>(
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<C : ItemClass<C>>(
}
}
} 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<C : ItemClass<C>>(
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
}