From c6f7cbae2bf6ceefb1a95ea2447429f685f3d056 Mon Sep 17 00:00:00 2001 From: Christian Basler Date: Thu, 25 Dec 2025 11:35:45 +0100 Subject: [PATCH] Add Keyboard Control (WIP) --- .../kotlin/ch/dissem/yaep/ui/common/App.kt | 25 ++++++- .../yaep/ui/common/focus/CluesFocusable.kt | 7 ++ .../ui/common/focus/CluesSelectionManager.kt | 72 +++++++++++++++++++ .../common/focus/FocusFollowingFocusable.kt | 7 +- .../focus/FocusFollowingSelectionManager.kt | 13 +++- .../dissem/yaep/ui/common/focus/Focusable.kt | 8 ++- .../yaep/ui/common/focus/GridFocusable.kt | 9 ++- .../ui/common/focus/GridSelectionManager.kt | 28 ++++++-- .../yaep/ui/common/focus/LinearFocusable.kt | 6 +- .../ui/common/focus/LinearSelectionManager.kt | 17 +++-- .../yaep/ui/common/focus/SelectionManager.kt | 36 +++++++++- .../kotlin/ch/dissem/yaep/ui/common/grid.kt | 66 +++++++++++++++-- .../yaep/ui/common/layout/FocusGroup.kt | 6 +- .../ui/common/layout/adaptive game layout.kt | 55 +++++++------- .../dissem/yaep/ui/common/layout/landscape.kt | 30 ++++---- .../dissem/yaep/ui/common/layout/portrait.kt | 31 ++++---- .../dissem/yaep/ui/common/layout/squarish.kt | 24 ++++--- .../kotlin/ch/dissem/yaep/ui/common/math.kt | 8 +++ .../ch/dissem/yaep/ui/common/selector.kt | 3 +- .../ch/dissem/yaep/ui/common/widget status.kt | 10 +-- 20 files changed, 357 insertions(+), 104 deletions(-) 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/math.kt 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 8c07a3f..5594fc5 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 @@ -73,7 +73,16 @@ fun App( for (clue in horizontalClues) { HorizontalClue( modifier = Modifier - .focus(remember { selectionManager.add() }) + .focus(remember(selectionManager) { + selectionManager.add( + primaryAction = { + clue.isActive = !clue.isActive + }, + secondaryAction = { + clue.isActive = false + } + ) + }) .forClue(clue, spacing), spacing = spacing, clue = clue.clue, @@ -86,7 +95,16 @@ fun App( for (clue in verticalClues) { VerticalClue( modifier = Modifier - .focus(remember { selectionManager.add() }) + .focus(remember(selectionManager) { + selectionManager.add( + primaryAction = { + clue.isActive = !clue.isActive + }, + secondaryAction = { + clue.isActive = false + } + ) + }) .forClue(clue, spacing), spacing = spacing, clue = clue.clue, @@ -102,7 +120,8 @@ fun App( textAlign = TextAlign.End ) }, - spacing = spacing + spacing = spacing, + game, resetCluesBeacon ) EndOfGame(isSolved = isSolved, time = time, onRestart = onNewGame) } 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 index dedc086..3ec0bfb 100644 --- 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 @@ -3,8 +3,11 @@ package ch.dissem.yaep.ui.common.focus import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusState -class FocusFollowingFocusable(manager: FocusFollowingSelectionManager) : - Focusable(manager) { +class FocusFollowingFocusable( + manager: FocusFollowingSelectionManager, + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? +) : Focusable(manager, primaryAction, secondaryAction) { val focusRequester = FocusRequester() 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 index 11bab76..e0ae99f 100644 --- 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 @@ -7,11 +7,18 @@ object FocusFollowingSelectionManager : SelectionManager Unit)?, + secondaryAction: (() -> Unit)? + ): FocusFollowingFocusable { + return FocusFollowingFocusable( + manager = this, + primaryAction = primaryAction, + secondaryAction = secondaryAction + ) } // Key events are ignored, the default focus mechanisms are used - override fun onKeyEvent(event: KeyEvent): Boolean = false + 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 index d22f1a2..d49571e 100644 --- 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 @@ -1,9 +1,15 @@ 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) { +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 -> 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 index b359b41..79c5a30 100644 --- 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 @@ -1,3 +1,10 @@ package ch.dissem.yaep.ui.common.focus -class GridFocusable(manager: GridSelectionManager) : Focusable(manager) \ No newline at end of file +import androidx.compose.ui.input.key.KeyEvent + +class GridFocusable( + manager: GridSelectionManager, + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)?, + onKeyEvent: ((KeyEvent) -> Boolean)? = null +) : 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 index 9139c37..052c4a5 100644 --- 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 @@ -2,9 +2,7 @@ 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 class GridSelectionManager : SelectionManager() { @@ -18,14 +16,32 @@ class GridSelectionManager : SelectionManager() { return this } - override fun add(): GridFocusable { - val new = GridFocusable(this) + fun add(onKeyEvent: (KeyEvent) -> Boolean): GridFocusable { + val new = GridFocusable( + manager = this, + primaryAction = null, + secondaryAction = null, + onKeyEvent = onKeyEvent + ) grid.last().add(new) return new } - override fun onKeyEvent(event: KeyEvent): Boolean { - if (event.type != KeyEventType.KeyUp) return false + override fun add( + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? + ): GridFocusable { + val new = GridFocusable( + manager = this, + primaryAction = primaryAction, + secondaryAction = secondaryAction + ) + grid.last().add(new) + return new + } + + override fun doOnKeyEvent(event: KeyEvent): Boolean { + if (grid.isEmpty()) return false when (event.key) { Key.DirectionDown -> { 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 index f22a0b3..d6e8ae8 100644 --- 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 @@ -1,6 +1,10 @@ package ch.dissem.yaep.ui.common.focus -class LinearFocusable(manager: LinearSelectionManager) : Focusable(manager) { +class LinearFocusable( + manager: LinearSelectionManager, + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? +) : Focusable(manager, primaryAction, secondaryAction) { var previous: LinearFocusable = this private set 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 index 4aa1573..809a9c0 100644 --- 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 @@ -2,10 +2,8 @@ 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.isShiftPressed import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.type class LinearSelectionManager( val keyNext: Key, @@ -20,8 +18,15 @@ class LinearSelectionManager( focused = focused?.previous } - override fun add(): LinearFocusable { - val new = LinearFocusable(this) + override fun add( + primaryAction: (() -> Unit)?, + secondaryAction: (() -> Unit)? + ): LinearFocusable { + val new = LinearFocusable( + manager = this, + primaryAction = primaryAction, + secondaryAction = secondaryAction + ) if (focused != null) { new.next = focused!! } else { @@ -30,9 +35,7 @@ class LinearSelectionManager( return new } - override fun onKeyEvent(event: KeyEvent): Boolean { - if (event.type != KeyEventType.KeyUp) return false - + override fun doOnKeyEvent(event: KeyEvent): Boolean { if (event.key == keyNext) { if (keyPrevious == null && event.isShiftPressed) { focusPrevious() 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 index e53f566..ac08171 100644 --- 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 @@ -1,6 +1,10 @@ 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> { @@ -24,6 +28,34 @@ abstract class SelectionManager> { } } - abstract fun add(): F - abstract fun onKeyEvent(event: KeyEvent): Boolean + 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 c133b52..21b5834 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 @@ -12,6 +12,10 @@ 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.unit.Dp import androidx.compose.ui.unit.dp import ch.dissem.yaep.domain.GameCell @@ -37,7 +41,7 @@ fun PuzzleGrid( onSnapshot = { grid.snapshot() }, onUndo = { grid.undo() }, spacing = spacing, - selectionManager = remember { selectionManager.addRow() } + selectionManager = remember(selectionManager) { selectionManager.addRow() } ) } } @@ -72,17 +76,42 @@ private fun PuzzleRow( onUpdate() } } + val focusable = remember(cell, selectionManager) { + selectionManager.add { e -> + if (selection != null) { + when (e.key) { + Key.Spacebar, Key.Enter, Key.Delete, Key.Backspace -> { + selection = null + true + } + + else -> false + } + } else { + val i = getNumber(e) + if (i != null && i in 1..options.size) { + val selectedItem = options[i - 1].item + if (e.isShiftPressed) { + onOptionRemoved(row, cell, selectedItem, onSnapshot) + } else { + onSelectItem(row, cell, options, selectedItem, onSnapshot, onUndo) + } + true + } else { + false + } + } + } + } Selector( modifier = Modifier - .focus(remember { selectionManager.add() }) + .focus(focusable) .padding(spacing) .weight(1f), spacing, options = options, - onOptionRemoved = { - onSnapshot() - cell.options.remove(it) - row.cleanupOptions() + onOptionRemoved = { selectedItem -> + onOptionRemoved(row, cell, selectedItem, onSnapshot) }, onOptionAdded = { cell.options.add(it) @@ -96,6 +125,31 @@ private fun PuzzleRow( } } +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/FocusGroup.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/FocusGroup.kt index 4c4abbf..d96d392 100644 --- 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 @@ -1,13 +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 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 index bc2de50..0f63698 100644 --- 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 @@ -2,17 +2,17 @@ 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.input.key.Key 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 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.LinearSelectionManager 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 @@ -29,20 +29,21 @@ fun AdaptiveGameLayout( horizontalClues: @Composable (SelectionManager<*>) -> Unit, verticalClues: @Composable (SelectionManager<*>) -> Unit, time: @Composable () -> Unit, - spacing: Dp = 8.dp + spacing: Dp = 8.dp, + vararg resetBeacons: Any ) { - val gridFocusable = selectionManager.add() - val gridSelectionManager = gridFocusable.create(GridSelectionManager()) + val gridFocusable = remember(*resetBeacons) { selectionManager.add() } + val gridSelectionManager = remember(*resetBeacons) { gridFocusable.create(GridSelectionManager()) } - val horizontalCluesFocusable = selectionManager.add() - val horizontalCluesSelectionManager = horizontalCluesFocusable.create( - LinearSelectionManager(Key.DirectionRight, Key.DirectionLeft) - ) + val horizontalCluesFocusable = remember(*resetBeacons) { selectionManager.add() } + val horizontalCluesSelectionManager = remember(*resetBeacons) { + horizontalCluesFocusable.create(CluesSelectionManager()) + } - val verticalCluesFocusable = selectionManager.add() - val verticalCluesSelectionManager = verticalCluesFocusable.create( - LinearSelectionManager(Key.DirectionRight, Key.DirectionLeft) - ) + val verticalCluesFocusable = remember(*resetBeacons) { selectionManager.add() } + val verticalCluesSelectionManager = remember(*resetBeacons) { + verticalCluesFocusable.create(CluesSelectionManager()) + } Layout( contents = listOf( { grid(gridSelectionManager) }, @@ -63,16 +64,18 @@ fun AdaptiveGameLayout( val aspectRatio = AspectRatio.from(constraints) val gridGroup = FocusGroup( - measurables[0], - measurables[1][0], + items = measurables[0], + box = measurables[1][0], ) val horizontalCluesGroup = FocusGroup( - measurables[2], - measurables[3][0] + items = measurables[2], + box = measurables[3][0], + selectionManger = horizontalCluesSelectionManager ) val verticalCluesGroup = FocusGroup( - measurables[4], - measurables[5][0] + items = measurables[4], + box = measurables[5][0], + selectionManger = verticalCluesSelectionManager ) val timeMeasurable = measurables[6][0] val dividerMeasurables = measurables[7] @@ -122,11 +125,15 @@ fun AdaptiveGameLayout( internal fun cluesBoxConstraints( width: Int, itemConstraints: Constraints, - itemCount: Int -): Constraints = fixed( - width, - itemConstraints.maxHeight * ceil(itemCount.toFloat() / (width / itemConstraints.maxWidth)).toInt() -) + 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, 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 index aa2adfd..5721584 100644 --- 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 @@ -42,7 +42,7 @@ internal fun Placeable.PlacementScope.landscape( cluesBoxConstraints( width = rightBarWidth, itemConstraints = horizontalCluesConstraints, - itemCount = horizontalClues.count + group = horizontalClues ) ) @@ -54,7 +54,7 @@ internal fun Placeable.PlacementScope.landscape( cluesBoxConstraints( width = rightBarWidth, itemConstraints = verticalCluesConstraints, - itemCount = verticalClues.count + group = verticalClues ) ) @@ -75,19 +75,21 @@ internal fun Placeable.PlacementScope.landscape( maxWidth = rightBarWidth ) - // Add divider in between - dividerPlaceable.place(gridSize + 3 * spacingPx, offsetY + spacingPx) + 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 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( 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 index 4f6f796..96fab6c 100644 --- 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 @@ -47,7 +47,7 @@ internal fun Placeable.PlacementScope.portrait( cluesBoxConstraints( width = gridSize, itemConstraints = horizontalCluesConstraints, - itemCount = horizontalClues.count + group = horizontalClues ) ) val verticalCluesBoxPlaceable = verticalClues.box @@ -55,7 +55,7 @@ internal fun Placeable.PlacementScope.portrait( cluesBoxConstraints( width = gridSize, itemConstraints = verticalCluesConstraints, - itemCount = verticalClues.count + group = verticalClues ) ) @@ -76,20 +76,21 @@ internal fun Placeable.PlacementScope.portrait( maxWidth = gridSize ) - // 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 - ) - verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY) + 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 + ) + verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY) + } // Position the time val remainingSpace = constraints.maxHeight - offsetY if (remainingSpace < 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 index 4d39dc8..ec11722 100644 --- 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 @@ -40,7 +40,7 @@ internal fun Placeable.PlacementScope.squarish( cluesBoxConstraints( width = rightBarWidth, itemConstraints = horizontalCluesConstraints, - itemCount = horizontalClues.count + group = horizontalClues ) ) @@ -52,7 +52,7 @@ internal fun Placeable.PlacementScope.squarish( cluesBoxConstraints( width = rightBarWidth, itemConstraints = verticalCluesConstraints, - itemCount = verticalClues.count + group = verticalClues ) ) val timePlaceable = timeMeasurable.measure(timeConstraints) @@ -71,15 +71,17 @@ internal fun Placeable.PlacementScope.squarish( maxWidth = rightBarWidth ) - // Position the vertical clues - val verticalCluesOffsetY = gridSize + 2 * spacingPx - verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY) - placeClues( - placeables = verticalCluesPlaceables, - offsetX = 0, - offsetY = verticalCluesOffsetY, - maxWidth = gridSize - ) + 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( 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/selector.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/selector.kt index b0f1566..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 @@ -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 { 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 index b845d4d..8c15c57 100644 --- 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 @@ -16,9 +16,9 @@ import ch.dissem.yaep.ui.common.focus.FocusFollowingFocusable import ch.dissem.yaep.ui.common.focus.Focusable @Composable -fun Modifier.focus(holder: Focusable<*>): Modifier { +fun Modifier.focus(focusable: Focusable<*>): Modifier { var m = this - val hasFocus by holder.hasFocus.collectAsState(false) + val hasFocus by focusable.hasFocus.collectAsState(false) if (hasFocus) { m = m.border( width = 2.dp, @@ -26,11 +26,11 @@ fun Modifier.focus(holder: Focusable<*>): Modifier { shape = RectangleShape ) } - if (holder is FocusFollowingFocusable) { + if (focusable is FocusFollowingFocusable) { m = m - .focusRequester(holder.focusRequester) + .focusRequester(focusable.focusRequester) .onFocusEvent { state -> - holder.setFocus(state) + focusable.setFocus(state) } .onFocusChanged {} .focusable()