Add Keyboard Control (WIP)

This commit is contained in:
2025-12-25 11:35:45 +01:00
committed by Christian Basler
parent 3582196720
commit c6f7cbae2b
20 changed files with 357 additions and 104 deletions

View File

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

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

@@ -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<FocusFollowingFocusable>(manager) {
class FocusFollowingFocusable(
manager: FocusFollowingSelectionManager,
primaryAction: (() -> Unit)?,
secondaryAction: (() -> Unit)?
) : Focusable<FocusFollowingFocusable>(manager, primaryAction, secondaryAction) {
val focusRequester = FocusRequester()

View File

@@ -7,11 +7,18 @@ object FocusFollowingSelectionManager : SelectionManager<FocusFollowingFocusable
isActive = true
}
override fun add(): FocusFollowingFocusable {
return FocusFollowingFocusable(this)
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 onKeyEvent(event: KeyEvent): Boolean = false
override fun doOnKeyEvent(event: KeyEvent): Boolean = focused?.child?.onKeyEvent(event) == true
}

View File

@@ -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<F : Focusable<F>>(protected val manager: SelectionManager<F>) {
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 ->

View File

@@ -1,3 +1,10 @@
package ch.dissem.yaep.ui.common.focus
class GridFocusable(manager: GridSelectionManager) : Focusable<GridFocusable>(manager)
import androidx.compose.ui.input.key.KeyEvent
class GridFocusable(
manager: GridSelectionManager,
primaryAction: (() -> Unit)?,
secondaryAction: (() -> Unit)?,
onKeyEvent: ((KeyEvent) -> Boolean)? = null
) : Focusable<GridFocusable>(manager, primaryAction, secondaryAction, onKeyEvent)

View File

@@ -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<GridFocusable>() {
@@ -18,14 +16,32 @@ class GridSelectionManager : SelectionManager<GridFocusable>() {
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 -> {

View File

@@ -1,6 +1,10 @@
package ch.dissem.yaep.ui.common.focus
class LinearFocusable(manager: LinearSelectionManager) : Focusable<LinearFocusable>(manager) {
class LinearFocusable(
manager: LinearSelectionManager,
primaryAction: (() -> Unit)?,
secondaryAction: (() -> Unit)?
) : Focusable<LinearFocusable>(manager, primaryAction, secondaryAction) {
var previous: LinearFocusable = this
private set

View File

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

View File

@@ -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<F : Focusable<F>> {
@@ -24,6 +28,34 @@ abstract class SelectionManager<F : Focusable<F>> {
}
}
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
}

View File

@@ -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<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(
row: GameRow<ItemClass<*>>,
cell: GameCell<ItemClass<*>>,

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -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 <C : ItemClass<C>> Selector(
if (selectedItem != null) {
DrawItem(
item = selectedItem,
modifier = modifier.clickable { onSelectItem(null) },
modifier = modifier.onEitherPointerAction { onSelectItem(null) },
spacing = radius
)
} else {

View File

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