Add keyboard control (WIP)

This commit is contained in:
2025-06-02 06:06:11 +02:00
parent da8a3c5398
commit 651c74e305
4 changed files with 308 additions and 1 deletions

View File

@@ -15,12 +15,12 @@ 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.domain.HorizontalClue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlin.coroutines.CoroutineContext
import kotlin.time.ExperimentalTime
@Composable
@OptIn(ExperimentalTime::class)
fun App(

View File

@@ -0,0 +1,184 @@
package ch.dissem.yaep.ui.common
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.unit.dp
import ch.dissem.yaep.domain.Clue
import ch.dissem.yaep.domain.Grid
import ch.dissem.yaep.domain.HorizontalClue
import ch.dissem.yaep.domain.ItemClass
import ch.dissem.yaep.domain.NeighbourClue
import ch.dissem.yaep.domain.OrderClue
import ch.dissem.yaep.domain.SameColumnClue
import ch.dissem.yaep.domain.TripletClue
import org.jetbrains.compose.resources.painterResource
import yaep.commonui.generated.resources.Res
import yaep.commonui.generated.resources.neighbour
import yaep.commonui.generated.resources.order
class DisplayClue<C : Clue>(val clue: C) {
var isActive: Boolean by mutableStateOf(true)
var isViolated: Boolean by mutableStateOf(false)
fun update(grid: Grid) {
isViolated = !clue.isValid(grid)
if (isViolated) {
isActive = true
}
}
}
@Composable
fun PuzzleClues(
modifier: Modifier = Modifier.Companion,
horizontalClues: List<DisplayClue<HorizontalClue>>,
verticalClues: List<DisplayClue<SameColumnClue<ItemClass<*>, ItemClass<*>>>>
) {
Column(modifier = modifier) {
LazyVerticalGrid(
modifier = Modifier.Companion.fillMaxWidth().wrapContentHeight(),
columns = GridCells.Fixed(4)
) {
for (clue in horizontalClues) {
item {
HorizontalClue(
modifier = Modifier.Companion
.forClue(clue),
clue = clue.clue,
isClueViolated = clue.isViolated
)
}
}
}
HorizontalDivider()
LazyVerticalGrid(
modifier = Modifier.Companion.fillMaxWidth().wrapContentHeight(),
columns = GridCells.Fixed(8)
) {
for (clue in verticalClues) {
item {
VerticalClue(
modifier = Modifier.Companion
.forClue(clue)
.aspectRatio(0.33333334f),
clue = clue.clue,
isClueViolated = clue.isViolated
)
}
}
}
}
}
private fun Modifier.forClue(clue: DisplayClue<out Clue>): Modifier = this
.alpha(if (clue.isActive) 1f else 0.2f)
.padding(8.dp)
.onEitherPointerAction { clue.isActive = !clue.isActive }
@Composable
fun HorizontalClue(
modifier: Modifier = Modifier.Companion,
clue: HorizontalClue,
isClueViolated: Boolean
) {
ClueCard(
modifier = modifier,
isClueViolated = isClueViolated
) {
Row {
when (clue) {
is NeighbourClue<*, *> -> {
DrawItem(modifier = Modifier.Companion.weight(1f), clue.a)
Image(
modifier = Modifier.Companion.aspectRatio(1f).weight(1f),
painter = painterResource(Res.drawable.neighbour),
contentDescription = null
)
DrawItem(modifier = Modifier.Companion.weight(1f), clue.b)
}
is OrderClue<*, *> -> {
DrawItem(modifier = Modifier.Companion.weight(1f), clue.left)
Image(
modifier = Modifier.Companion.aspectRatio(1f).weight(1f),
painter = painterResource(Res.drawable.order),
contentDescription = null
)
DrawItem(modifier = Modifier.Companion.weight(1f), clue.right)
}
is TripletClue<*, *, *> -> {
DrawItem(modifier = Modifier.Companion.weight(1f), clue.a)
DrawItem(modifier = Modifier.Companion.weight(1f), clue.b)
DrawItem(modifier = Modifier.Companion.weight(1f), clue.c)
}
}
}
}
}
@Composable
fun VerticalClue(
modifier: Modifier = Modifier.Companion,
clue: SameColumnClue<*, *>,
isClueViolated: Boolean = false
) {
ClueCard(
modifier = modifier.aspectRatio(0.5f),
isClueViolated = isClueViolated
) {
Column {
DrawItem(modifier = Modifier.Companion.weight(1f), clue.a)
DrawItem(modifier = Modifier.Companion.weight(1f), clue.b)
}
}
}
@Composable
fun ClueCard(
modifier: Modifier = Modifier.Companion,
isClueViolated: Boolean,
content: @Composable () -> Unit
) {
val colors = MaterialTheme.colorScheme
OutlinedCard(
modifier = if (isClueViolated) {
modifier.shadow(
8.dp,
shape = CardDefaults.outlinedShape,
ambientColor = colors.error,
spotColor = colors.error
)
} else {
modifier
},
border = if (isClueViolated) {
remember { BorderStroke(1.0.dp, colors.error) }
} else {
CardDefaults.outlinedCardBorder()
}
) {
content()
}
}

View File

@@ -0,0 +1,79 @@
package ch.dissem.yaep.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import ch.dissem.yaep.domain.Grid
@Composable
fun PuzzleGrid(
modifier: Modifier = Modifier.Companion,
grid: Grid,
onUpdate: () -> Unit
) {
Column(modifier = modifier) {
for (row in grid) {
Row(
modifier = Modifier.Companion
.fillMaxWidth()
.wrapContentHeight()
) {
val allOptions = row.options
for (item in row) {
var selection by remember(item) { mutableStateOf(item.selection) }
val options = remember(item) {
allOptions.map { Toggleable(it, item.options.contains(it)) }
}
LaunchedEffect(item) {
item.optionsChangedListeners.add { enabled ->
options.forEach { it.enabled = enabled.contains(it.item) }
}
item.selectionChangedListeners.add {
selection = it
onUpdate()
}
}
Selector(
modifier = Modifier.Companion
.padding(8.dp)
.weight(1f),
options = options,
onOptionRemoved = {
grid.snapshot()
item.options.remove(it)
row.cleanupOptions()
},
onOptionAdded = {
item.options.add(it)
},
selectedItem = selection,
onSelectItem = {
if (it != null) {
grid.snapshot()
item.selection = it
row.cleanupOptions()
} else {
while (item.selection != null) {
if (!grid.undo()) break
}
options.forEach {
it.enabled = item.options.contains(it.item)
}
}
}
)
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
import androidx.compose.foundation.border
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
class StatusManager {
lateinit var focused: StatusHolder
private set
fun focusNext() {
focused = focused.next
}
fun focusPrevious() {
focused = focused.previous
}
}
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
}
}
@Composable
fun Modifier.status(holder: StatusHolder): Modifier = if (holder.hasFocus) {
border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = RectangleShape
)
} else {
this
}