From e0de7be85764d0aa1cdf59519a7898e8412579d5 Mon Sep 17 00:00:00 2001 From: Christian Basler Date: Mon, 16 Jun 2025 00:23:36 +0200 Subject: [PATCH] Add keyboard control (WIP) --- .../kotlin/ch/dissem/yaep/ui/common/App.kt | 15 ++- .../ch/dissem/yaep/ui/common/widget status.kt | 113 +++++++++++++++--- .../dissem/yaep/ui/desktop/desktop window.kt | 5 + .../kotlin/ch/dissem/yaep/ui/desktop/main.kt | 4 + 4 files changed, 118 insertions(+), 19 deletions(-) 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 ee7e92c..8cb2d8b 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 @@ -1,5 +1,6 @@ package ch.dissem.yaep.ui.common +import SelectionManager import androidx.compose.foundation.layout.Box import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,6 +16,7 @@ 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 focus import ch.dissem.yaep.domain.HorizontalClue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -25,6 +27,7 @@ import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) fun App( modifier: Modifier = Modifier, + selectionManager: SelectionManager, selectDirectly: Boolean, spacing: Dp, game: Game, @@ -56,6 +59,8 @@ fun App( modifier = Modifier.blurOnFinished(isSolved), grid = { PuzzleGrid( + modifier = Modifier + .focus(remember { selectionManager.add() }), grid = game.grid, spacing = spacing, selectDirectly = selectDirectly, @@ -66,9 +71,12 @@ fun App( ) }, horizontalClues = { + val horizontalClueSelection = remember { selectionManager.add() } for (clue in horizontalClues) { HorizontalClue( - modifier = Modifier.forClue(clue, spacing), + modifier = Modifier + .focus(horizontalClueSelection) + .forClue(clue, spacing), spacing = spacing, clue = clue.clue, isClueViolated = clue.isViolated @@ -76,9 +84,12 @@ fun App( } }, verticalClues = { + val verticalClueSelection = remember { selectionManager.add() } for (clue in verticalClues) { VerticalClue( - modifier = Modifier.forClue(clue, spacing), + modifier = Modifier + .focus(verticalClueSelection) + .forClue(clue, spacing), spacing = spacing, clue = clue.clue, isClueViolated = clue.isViolated 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 dd1e5db..dd95e11 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 @@ -1,39 +1,118 @@ import androidx.compose.foundation.border import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +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 import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlin.IllegalStateException -class StatusManager { - lateinit var focused: StatusHolder - private set +class SelectionManager( + val keyNext: Key, + val keyPrevious: Key? = null +) { + var isActiveFlow = MutableStateFlow(false) + var isActive: Boolean + get() = isActiveFlow.value + set(value) { + isActiveFlow.value = value + } + + var focusedFlow = MutableStateFlow(null) + + var focused: Focusable? + get() = focusedFlow.value + set(value) { + focusedFlow.value = value + } + + val last: Focusable + get() = focused?.previous ?: throw IllegalStateException("not initialized") + + val child: SelectionManager? + get() = focused?.child fun focusNext() { - focused = focused.next + focused = focused?.next } fun focusPrevious() { - focused = focused.previous + focused = focused?.previous + } + + fun add(): Focusable { + val new = Focusable(this) + if (focused != null) { + new.next = focused!! + } else { + focused = new + } + return new + } + + fun onKeyEvent(event: KeyEvent): Boolean { + if (event.type != KeyEventType.KeyUp) return false + + if (event.key == keyNext) { + if (keyPrevious == null && event.isShiftPressed) { + focusPrevious() + } else { + focusNext() + } + } else if (event.key == keyPrevious) { + focusPrevious() + } else { + return child?.onKeyEvent(event) == true + } + return true } } -class StatusHolder(private val manager: StatusManager) { - val hasFocus: Boolean - get() = manager.focused == this - var isSelected: Boolean = false - - var previous: StatusHolder = this - private set - var next: StatusHolder = this - set(value) { - field = value - value.previous = this +class Focusable( + manager: SelectionManager, +) { + val hasFocus: Flow = + combine(manager.isActiveFlow, manager.focusedFlow) { isActive, focused -> + isActive && focused == this } + + var previous: Focusable = this + private set + + private var _next: Focusable = this + var next: Focusable + get() = _next + set(value) { + previous = value.previous + previous._next = this + value.previous = this + _next = value + } + + var child: SelectionManager? = null + private set + + fun createChild( + keyNext: Key, + keyPrevious: Key? = null + ): SelectionManager { + child = SelectionManager(keyNext, keyPrevious) + child!!.isActive = false + return child!! + } } @Composable -fun Modifier.status(holder: StatusHolder): Modifier = if (holder.hasFocus) { +fun Modifier.focus(holder: Focusable): Modifier = if (holder.hasFocus.collectAsState(false).value) { border( width = 2.dp, color = MaterialTheme.colorScheme.primary, diff --git a/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/desktop window.kt b/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/desktop window.kt index 15956a0..c5c9b2b 100644 --- a/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/desktop window.kt +++ b/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/desktop window.kt @@ -1,5 +1,6 @@ package ch.dissem.yaep.ui.desktop +import SelectionManager import androidx.compose.foundation.Image import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -16,6 +17,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +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 @@ -40,11 +43,13 @@ import yaep.desktop.generated.resources.Res as DRes @Composable fun WindowScope.DesktopWindow( useDarkMode: Boolean, + selectionManager: SelectionManager, topBar: @Composable () -> Unit, content: @Composable (PaddingValues) -> Unit ) { AppTheme(darkTheme = useDarkMode) { Scaffold( + modifier = Modifier.onKeyEvent { event -> selectionManager.onKeyEvent(event) }, topBar = { WindowDraggableArea { topBar() diff --git a/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/main.kt b/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/main.kt index 0c2b3c9..ab34830 100644 --- a/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/main.kt +++ b/desktop/src/main/kotlin/ch/dissem/yaep/ui/desktop/main.kt @@ -6,6 +6,7 @@ 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.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight @@ -47,10 +48,12 @@ fun main(): Unit = application { state = windowState, icon = painterResource(DRes.drawable.ic_launcher) ) { + val selectionManager = remember { SelectionManager(Key.Tab).apply { isActive = true } } var useDarkMode by remember { mutableStateOf(true) } var resetCluesBeacon by remember { mutableStateOf(Any()) } DesktopWindow( useDarkMode = useDarkMode, + selectionManager = selectionManager, topBar = { AppBar( useDarkMode = useDarkMode, @@ -67,6 +70,7 @@ fun main(): Unit = application { ) { App( modifier = Modifier.padding(it), + selectionManager = selectionManager, spacing = 8.dp, selectDirectly = true, game = game,