Add keyboard control (WIP)

This commit is contained in:
2025-06-16 00:23:36 +02:00
parent 651c74e305
commit e0de7be857
4 changed files with 118 additions and 19 deletions

View File

@@ -1,5 +1,6 @@
package ch.dissem.yaep.ui.common package ch.dissem.yaep.ui.common
import SelectionManager
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.TextUnit
import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.TextUnitType
import ch.dissem.yaep.domain.Game import ch.dissem.yaep.domain.Game
import focus
import ch.dissem.yaep.domain.HorizontalClue import ch.dissem.yaep.domain.HorizontalClue
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -25,6 +27,7 @@ import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
fun App( fun App(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
selectionManager: SelectionManager,
selectDirectly: Boolean, selectDirectly: Boolean,
spacing: Dp, spacing: Dp,
game: Game, game: Game,
@@ -56,6 +59,8 @@ fun App(
modifier = Modifier.blurOnFinished(isSolved), modifier = Modifier.blurOnFinished(isSolved),
grid = { grid = {
PuzzleGrid( PuzzleGrid(
modifier = Modifier
.focus(remember { selectionManager.add() }),
grid = game.grid, grid = game.grid,
spacing = spacing, spacing = spacing,
selectDirectly = selectDirectly, selectDirectly = selectDirectly,
@@ -66,9 +71,12 @@ fun App(
) )
}, },
horizontalClues = { horizontalClues = {
val horizontalClueSelection = remember { selectionManager.add() }
for (clue in horizontalClues) { for (clue in horizontalClues) {
HorizontalClue( HorizontalClue(
modifier = Modifier.forClue(clue, spacing), modifier = Modifier
.focus(horizontalClueSelection)
.forClue(clue, spacing),
spacing = spacing, spacing = spacing,
clue = clue.clue, clue = clue.clue,
isClueViolated = clue.isViolated isClueViolated = clue.isViolated
@@ -76,9 +84,12 @@ fun App(
} }
}, },
verticalClues = { verticalClues = {
val verticalClueSelection = remember { selectionManager.add() }
for (clue in verticalClues) { for (clue in verticalClues) {
VerticalClue( VerticalClue(
modifier = Modifier.forClue(clue, spacing), modifier = Modifier
.focus(verticalClueSelection)
.forClue(clue, spacing),
spacing = spacing, spacing = spacing,
clue = clue.clue, clue = clue.clue,
isClueViolated = clue.isViolated isClueViolated = clue.isViolated

View File

@@ -1,39 +1,118 @@
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape 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 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 { class SelectionManager(
lateinit var focused: StatusHolder val keyNext: Key,
private set val keyPrevious: Key? = null
) {
var isActiveFlow = MutableStateFlow<Boolean>(false)
var isActive: Boolean
get() = isActiveFlow.value
set(value) {
isActiveFlow.value = value
}
var focusedFlow = MutableStateFlow<Focusable?>(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() { fun focusNext() {
focused = focused.next focused = focused?.next
} }
fun focusPrevious() { 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) { class Focusable(
val hasFocus: Boolean manager: SelectionManager,
get() = manager.focused == this ) {
var isSelected: Boolean = false val hasFocus: Flow<Boolean> =
combine(manager.isActiveFlow, manager.focusedFlow) { isActive, focused ->
isActive && focused == this
}
var previous: StatusHolder = this var previous: Focusable = this
private set private set
var next: StatusHolder = this
private var _next: Focusable = this
var next: Focusable
get() = _next
set(value) { set(value) {
field = value previous = value.previous
previous._next = this
value.previous = 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 @Composable
fun Modifier.status(holder: StatusHolder): Modifier = if (holder.hasFocus) { fun Modifier.focus(holder: Focusable): Modifier = if (holder.hasFocus.collectAsState(false).value) {
border( border(
width = 2.dp, width = 2.dp,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,

View File

@@ -1,5 +1,6 @@
package ch.dissem.yaep.ui.desktop package ch.dissem.yaep.ui.desktop
import SelectionManager
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -16,6 +17,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.unit.dp
import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowScope import androidx.compose.ui.window.WindowScope
@@ -40,11 +43,13 @@ import yaep.desktop.generated.resources.Res as DRes
@Composable @Composable
fun WindowScope.DesktopWindow( fun WindowScope.DesktopWindow(
useDarkMode: Boolean, useDarkMode: Boolean,
selectionManager: SelectionManager,
topBar: @Composable () -> Unit, topBar: @Composable () -> Unit,
content: @Composable (PaddingValues) -> Unit content: @Composable (PaddingValues) -> Unit
) { ) {
AppTheme(darkTheme = useDarkMode) { AppTheme(darkTheme = useDarkMode) {
Scaffold( Scaffold(
modifier = Modifier.onKeyEvent { event -> selectionManager.onKeyEvent(event) },
topBar = { topBar = {
WindowDraggableArea { WindowDraggableArea {
topBar() topBar()

View File

@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -47,10 +48,12 @@ fun main(): Unit = application {
state = windowState, state = windowState,
icon = painterResource(DRes.drawable.ic_launcher) icon = painterResource(DRes.drawable.ic_launcher)
) { ) {
val selectionManager = remember { SelectionManager(Key.Tab).apply { isActive = true } }
var useDarkMode by remember { mutableStateOf(true) } var useDarkMode by remember { mutableStateOf(true) }
var resetCluesBeacon by remember { mutableStateOf(Any()) } var resetCluesBeacon by remember { mutableStateOf(Any()) }
DesktopWindow( DesktopWindow(
useDarkMode = useDarkMode, useDarkMode = useDarkMode,
selectionManager = selectionManager,
topBar = { topBar = {
AppBar( AppBar(
useDarkMode = useDarkMode, useDarkMode = useDarkMode,
@@ -67,6 +70,7 @@ fun main(): Unit = application {
) { ) {
App( App(
modifier = Modifier.padding(it), modifier = Modifier.padding(it),
selectionManager = selectionManager,
spacing = 8.dp, spacing = 8.dp,
selectDirectly = true, selectDirectly = true,
game = game, game = game,