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 713f1d9..8c07a3f 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 @@ -15,7 +15,9 @@ 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.ui.common.focus.FocusFollowingFocusable import ch.dissem.yaep.ui.common.focus.SelectionManager +import ch.dissem.yaep.ui.common.layout.AdaptiveGameLayout import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -26,7 +28,7 @@ import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) fun App( modifier: Modifier = Modifier, - rootSelectionManager: SelectionManager<*>, + rootSelectionManager: SelectionManager, spacing: Dp, game: Game, onNewGame: () -> Unit, @@ -79,7 +81,7 @@ fun App( ) } }, - verticalClues = {selectionManager -> + verticalClues = { selectionManager -> if (verticalClues.isNotEmpty()) { for (clue in verticalClues) { VerticalClue( diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/adaptive game layout.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/adaptive game layout.kt deleted file mode 100644 index 3f7dd8e..0000000 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/adaptive game layout.kt +++ /dev/null @@ -1,480 +0,0 @@ -package ch.dissem.yaep.ui.common - -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.Measurable -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.Constraints.Companion.fixedWidth -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import ch.dissem.yaep.ui.common.AspectRatio.LANDSCAPE -import ch.dissem.yaep.ui.common.AspectRatio.PORTRAIT -import ch.dissem.yaep.ui.common.AspectRatio.SQUARISH -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 kotlin.math.ceil -import kotlin.math.max -import kotlin.math.min - - -private enum class AspectRatio { - PORTRAIT, LANDSCAPE, SQUARISH; - - companion object { - private const val ASPECT_RATIO_LANDSCAPE = 1.4f - private const val ASPECT_RATIO_PORTRAIT = 1 / ASPECT_RATIO_LANDSCAPE - - fun from(constraints: Constraints): AspectRatio { - val ratio = constraints.maxWidth.toFloat() / constraints.maxHeight.toFloat() - return when { - ratio < ASPECT_RATIO_PORTRAIT -> PORTRAIT - ratio > ASPECT_RATIO_LANDSCAPE -> LANDSCAPE - else -> SQUARISH - } - } - } -} - -@Composable -fun AdaptiveGameLayout( - modifier: Modifier = Modifier, - selectionManager: SelectionManager<*>, - grid: @Composable (GridSelectionManager) -> Unit, - horizontalClues: @Composable (SelectionManager<*>) -> Unit, - verticalClues: @Composable (SelectionManager<*>) -> Unit, - time: @Composable () -> Unit, - divider: @Composable () -> Unit = { HorizontalDivider() }, - spacing: Dp = 8.dp -) { - val gridFocusable = selectionManager.add() - val gridSelectionManager = gridFocusable.create(GridSelectionManager()) - - val horizontalCluesFocusable = selectionManager.add() - val horizontalCluesSelectionManager = horizontalCluesFocusable.create( - LinearSelectionManager(Key.DirectionRight, Key.DirectionLeft) - ) - - val verticalCluesFocusable = selectionManager.add() - val verticalCluesSelectionManager = verticalCluesFocusable.create( - LinearSelectionManager(Key.DirectionRight, Key.DirectionLeft) - ) - Layout( - contents = listOf( - { - Box(modifier = Modifier.focus(gridFocusable)) - LaunchedEffect(Unit) { - if (gridFocusable is FocusFollowingFocusable) { - gridFocusable.focusRequester.requestFocus() - } - } - }, - { Box(modifier = Modifier.focus(horizontalCluesFocusable)) }, - { Box(modifier = Modifier.focus(verticalCluesFocusable)) }, - { grid(gridSelectionManager) }, - { horizontalClues(horizontalCluesSelectionManager) }, - { verticalClues(verticalCluesSelectionManager) }, - time, - divider, - divider - ), - modifier = modifier - ) { measurables, constraints -> - layout(width = constraints.maxWidth, height = constraints.maxHeight) { - val aspectRatio = AspectRatio.from(constraints) - - val gridBoxMeasurable = measurables[0][0] - val horizontalCluesBoxMeasurable = measurables[1][0] - val verticalCluesBoxMeasurable = measurables[2][0] - - val gridMeasurable = measurables[3][0] - val horizontalCluesMeasurables = measurables[4] - val verticalCluesMeasurables = measurables[5] - val timeMeasurable = measurables[6][0] - val dividerMeasurable = measurables[7][0] - - val spacingPx = spacing.roundToPx() - - when (aspectRatio) { - PORTRAIT -> { - val divider2Measurable = measurables[8][0] - portrait( - constraints, - spacingPx, - gridMeasurable, - gridBoxMeasurable, - horizontalCluesMeasurables, - horizontalCluesBoxMeasurable, - verticalCluesMeasurables, - verticalCluesBoxMeasurable, - timeMeasurable, - dividerMeasurable, - divider2Measurable - ) - } - - SQUARISH -> { - squarish( - constraints, - spacingPx, - gridMeasurable, - gridBoxMeasurable, - horizontalCluesMeasurables, - horizontalCluesBoxMeasurable, - verticalCluesMeasurables, - verticalCluesBoxMeasurable, - timeMeasurable - ) - } - - LANDSCAPE -> { - landscape( - constraints, - spacingPx, - gridMeasurable, - gridBoxMeasurable, - horizontalCluesMeasurables, - horizontalCluesBoxMeasurable, - verticalCluesMeasurables, - verticalCluesBoxMeasurable, - timeMeasurable, - dividerMeasurable - ) - } - } - } - } -} - -private fun Placeable.PlacementScope.portrait( - constraints: Constraints, - spacingPx: Int, - gridMeasurable: Measurable, - gridBoxMeasurable: Measurable, - horizontalCluesMeasurables: List, - horizontalCluesBoxMeasurable: Measurable, - verticalCluesMeasurables: List, - verticalCluesBoxMeasurable: Measurable, - timeMeasurable: Measurable, - divider1Measurable: Measurable, - divider2Measurable: Measurable -) { - val gridSize = constraints.maxWidth - val gridItemSize = (gridSize - 12 * spacingPx) / 18 - - val gridConstraints = fixed(gridSize, gridSize) - val horizontalCluesConstraints = fixed( - width = 3 * gridItemSize + 2 * spacingPx, - height = gridItemSize + 2 * spacingPx - ) - val verticalCluesConstraints = fixed( - width = gridItemSize + 2 * spacingPx, - height = 3 * gridItemSize + 2 * spacingPx - ) - val timeConstraints = Constraints() - val dividerConstraints = fixedWidth(gridSize) - - val gridPlaceable = gridMeasurable.measure(gridConstraints) - val gridBoxPlaceable = gridBoxMeasurable.measure(gridConstraints) - val horizontalCluesPlaceables = horizontalCluesMeasurables.map { - it.measure(horizontalCluesConstraints) - } - val verticalCluesPlaceables = verticalCluesMeasurables.map { - it.measure(verticalCluesConstraints) - } - val timePlaceable = timeMeasurable.measure(timeConstraints) - val divider1Placeable = divider1Measurable.measure(dividerConstraints) - val divider2Placeable = divider2Measurable.measure(dividerConstraints) - - val horizontalCluesBoxPlaceable = horizontalCluesBoxMeasurable - .measure( - cluesBoxConstraints( - width = gridSize, - itemConstraints = horizontalCluesConstraints, - itemCount = horizontalCluesMeasurables.size - ) - ) - val verticalCluesBoxPlaceable = verticalCluesBoxMeasurable - .measure( - cluesBoxConstraints( - width = gridSize, - itemConstraints = verticalCluesConstraints, - itemCount = verticalCluesMeasurables.size - ) - ) - - // Position the grid - gridPlaceable.place(0, 0) - gridBoxPlaceable.place(0, 0) - - divider1Placeable.place(0, gridSize + spacingPx) - - // Position the horizontal clues - val horizontalCluesOffsetY = gridSize + 2 * spacingPx - - var offsetY = placeClues( - placeables = horizontalCluesPlaceables, - offsetX = 0, - offsetY = horizontalCluesOffsetY, - maxWidth = gridSize - ) - horizontalCluesBoxPlaceable.place(0, horizontalCluesOffsetY) - verticalCluesBoxPlaceable.place(0, horizontalCluesOffsetY) - - // Add divider in between - divider2Placeable.place(0, offsetY + spacingPx) - - // Position the vertical clues - val verticalCluesOffsetY = offsetY + spacingPx + divider2Placeable.height - 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) { - val scale = remainingSpace.toFloat() / timePlaceable.height.toFloat() - if (scale > 0.1f) { - timePlaceable.placeWithLayer( - x = constraints.maxWidth - timePlaceable.width - spacingPx, - y = constraints.maxHeight - timePlaceable.height - ) { - scaleX = scale - scaleY = scale - translationX = (timePlaceable.width * (1 - scale)) / 2f - translationY = (timePlaceable.height * (1 - scale)) / 2f - } - } - } else { - timePlaceable.place( - x = constraints.maxWidth - timePlaceable.width - spacingPx, - y = constraints.maxHeight - timePlaceable.height - ) - } -} - -private fun Placeable.PlacementScope.squarish( - constraints: Constraints, - spacingPx: Int, - gridMeasurable: Measurable, - gridBoxMeasurable: Measurable, - horizontalCluesMeasurables: List, - horizontalCluesBoxMeasurable: Measurable, - verticalCluesMeasurables: List, - verticalCluesBoxMeasurable: Measurable, - timeMeasurable: Measurable -) { - val gridSize = (7 * min(constraints.maxWidth, constraints.maxHeight)) / 10 - val gridItemSize = (gridSize - 12 * spacingPx) / 18 - val rightBarWidth = constraints.maxWidth - gridSize - spacingPx - - val gridConstraints = fixed(gridSize, gridSize) - val horizontalCluesConstraints = fixed( - width = 3 * gridItemSize + 2 * spacingPx, - height = gridItemSize + 2 * spacingPx - ) - val verticalCluesConstraints = fixed( - width = gridItemSize + 2 * spacingPx, - height = 3 * gridItemSize + 2 * spacingPx - ) - val timeConstraints = Constraints() - - val gridPlaceable = gridMeasurable.measure(gridConstraints) - val gridBoxPlaceable = gridBoxMeasurable.measure(gridConstraints) - val horizontalCluesPlaceables = horizontalCluesMeasurables.map { - it.measure(horizontalCluesConstraints) - } - val horizontalCluesBoxPlaceable = horizontalCluesBoxMeasurable - .measure( - cluesBoxConstraints( - width = rightBarWidth, - itemConstraints = horizontalCluesConstraints, - itemCount = horizontalCluesMeasurables.size - ) - ) - - val verticalCluesPlaceables = verticalCluesMeasurables.map { - it.measure(verticalCluesConstraints) - } - val verticalCluesBoxPlaceable = verticalCluesBoxMeasurable - .measure( - cluesBoxConstraints( - width = rightBarWidth, - itemConstraints = verticalCluesConstraints, - itemCount = verticalCluesMeasurables.size - ) - ) - val timePlaceable = timeMeasurable.measure(timeConstraints) - - // Position the grid - gridPlaceable.place(0, 0) - gridBoxPlaceable.place(0, 0) - - // Position the horizontal clues - val horizontalCluesOffsetX = gridSize + 2 * spacingPx - placeClues( - placeables = horizontalCluesPlaceables, - offsetX = horizontalCluesOffsetX, - offsetY = 0, - maxWidth = rightBarWidth - ) - horizontalCluesBoxPlaceable.place(horizontalCluesOffsetX, 0) - - // Position the vertical clues - val verticalCluesOffsetY = gridSize + 2 * spacingPx - placeClues( - placeables = verticalCluesPlaceables, - offsetX = 0, - offsetY = verticalCluesOffsetY, - maxWidth = gridSize - ) - verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY) - - // Position the time - timePlaceable.place( - x = constraints.maxWidth - timePlaceable.width - spacingPx, - y = constraints.maxHeight - timePlaceable.height - ) -} - -private fun Placeable.PlacementScope.landscape( - constraints: Constraints, - spacingPx: Int, - gridMeasurable: Measurable, - gridBoxMeasurable: Measurable, - horizontalCluesMeasurables: List, - horizontalCluesBoxMeasurable: Measurable, - verticalCluesMeasurables: List, - verticalCluesBoxMeasurable: Measurable, - timeMeasurable: Measurable, - dividerMeasurable: Measurable -) { - val gridSize = constraints.maxHeight - val gridItemSize = (gridSize - 12 * spacingPx) / 18 - val rightBarWidth = constraints.maxWidth - gridSize - 2 * spacingPx - - val gridConstraints = fixed(gridSize, gridSize) - val baseSpace = gridSize - 2 * spacingPx - val horizontalCluesConstraints = fixed( - width = 3 * gridItemSize + 2 * spacingPx, - height = gridItemSize + 2 * spacingPx - ) - val verticalCluesConstraints = fixed( - width = gridItemSize + 2 * spacingPx, - height = 3 * gridItemSize + 2 * spacingPx - ) - val timeConstraints = Constraints.fixedHeight(baseSpace / 10) - val dividerConstraints = fixedWidth(rightBarWidth - 2 * spacingPx) - - val gridPlaceable = gridMeasurable.measure(gridConstraints) - val gridBoxPlaceable = gridBoxMeasurable.measure(gridConstraints) - val horizontalCluesPlaceables = horizontalCluesMeasurables.map { - it.measure(horizontalCluesConstraints) - } - val horizontalCluesBoxPlaceable = horizontalCluesBoxMeasurable - .measure( - cluesBoxConstraints( - width = rightBarWidth, - itemConstraints = horizontalCluesConstraints, - itemCount = horizontalCluesMeasurables.size - ) - ) - - val verticalCluesPlaceables = verticalCluesMeasurables.map { - it.measure(verticalCluesConstraints) - } - val verticalCluesBoxPlaceable = verticalCluesBoxMeasurable - .measure( - cluesBoxConstraints( - width = rightBarWidth, - itemConstraints = verticalCluesConstraints, - itemCount = verticalCluesMeasurables.size - ) - ) - - val timePlaceable = timeMeasurable.measure(timeConstraints) - val dividerPlaceable = dividerMeasurable.measure(dividerConstraints) - - // Position the grid - gridPlaceable.place(0, 0) - gridBoxPlaceable.place(0, 0) - - // Position the horizontal clues4 - val horizontalCluesOffsetX = gridSize + 2 * spacingPx - val offsetY = placeClues( - placeables = horizontalCluesPlaceables, - offsetX = horizontalCluesOffsetX, - offsetY = 0, - maxWidth = rightBarWidth - ) - horizontalCluesBoxPlaceable.place(horizontalCluesOffsetX, 0) - - // 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 - placeClues( - placeables = verticalCluesPlaceables, - offsetX = verticalCluesOffsetX, - offsetY = verticalCluesOffsetY, - maxWidth = rightBarWidth - ) - verticalCluesBoxPlaceable.place(verticalCluesOffsetX, verticalCluesOffsetY) - - // Position the time - timePlaceable.place( - x = constraints.maxWidth - timePlaceable.width - spacingPx, - y = constraints.maxHeight - timePlaceable.height - ) -} - -private fun cluesBoxConstraints( - width: Int, - itemConstraints: Constraints, - itemCount: Int -): Constraints = fixed( - width, - itemConstraints.maxHeight * ceil(itemCount.toFloat() / (width / itemConstraints.maxWidth)).toInt() -) - -private fun Placeable.PlacementScope.placeClues( - placeables: List, - offsetX: Int, - offsetY: Int, - maxWidth: Int -): Int { - if (placeables.isEmpty()) return offsetY - - val itemWidth = placeables.first().width - val itemHeight = placeables.first().height - val columns = max(1, maxWidth / itemWidth) - val spacing = if (columns == 1) 0 else (maxWidth - columns * itemWidth) / (columns - 1) - var currentX = offsetX - var currentY = offsetY - var i = 0 - for (placeable in placeables) { - placeable.place(currentX, currentY) - currentX += itemWidth + spacing - i++ - if (i % columns == 0 && i < placeables.size) { - currentX = offsetX - currentY += itemHeight - } - } - return currentY + itemHeight -} - diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/AspectRatio.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/AspectRatio.kt new file mode 100644 index 0000000..2704451 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/AspectRatio.kt @@ -0,0 +1,21 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.ui.unit.Constraints + +enum class AspectRatio { + PORTRAIT, LANDSCAPE, SQUARISH; + + companion object { + private const val ASPECT_RATIO_LANDSCAPE = 1.4f + private const val ASPECT_RATIO_PORTRAIT = 1 / ASPECT_RATIO_LANDSCAPE + + fun from(constraints: Constraints): AspectRatio { + val ratio = constraints.maxWidth.toFloat() / constraints.maxHeight.toFloat() + return when { + ratio < ASPECT_RATIO_PORTRAIT -> PORTRAIT + ratio > ASPECT_RATIO_LANDSCAPE -> LANDSCAPE + else -> SQUARISH + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..4c4abbf --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/FocusGroup.kt @@ -0,0 +1,13 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.ui.layout.Measurable + +data class FocusGroup( + val items: List, + val box: Measurable +) { + val item: Measurable + get() = items.single() + + 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 new file mode 100644 index 0000000..bc2de50 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/adaptive game layout.kt @@ -0,0 +1,157 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +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.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 +import ch.dissem.yaep.ui.common.layout.AspectRatio.SQUARISH +import kotlin.math.ceil +import kotlin.math.max + + +@Composable +fun AdaptiveGameLayout( + modifier: Modifier = Modifier, + selectionManager: SelectionManager, + grid: @Composable (GridSelectionManager) -> Unit, + horizontalClues: @Composable (SelectionManager<*>) -> Unit, + verticalClues: @Composable (SelectionManager<*>) -> Unit, + time: @Composable () -> Unit, + spacing: Dp = 8.dp +) { + val gridFocusable = selectionManager.add() + val gridSelectionManager = gridFocusable.create(GridSelectionManager()) + + val horizontalCluesFocusable = selectionManager.add() + val horizontalCluesSelectionManager = horizontalCluesFocusable.create( + LinearSelectionManager(Key.DirectionRight, Key.DirectionLeft) + ) + + val verticalCluesFocusable = selectionManager.add() + val verticalCluesSelectionManager = verticalCluesFocusable.create( + LinearSelectionManager(Key.DirectionRight, Key.DirectionLeft) + ) + Layout( + contents = listOf( + { grid(gridSelectionManager) }, + { FocusBox(gridFocusable, requestFocus = true) }, + { horizontalClues(horizontalCluesSelectionManager) }, + { FocusBox(horizontalCluesFocusable) }, + { verticalClues(verticalCluesSelectionManager) }, + { FocusBox(verticalCluesFocusable) }, + time, + { + HorizontalDivider() + HorizontalDivider() + } + ), + modifier = modifier + ) { measurables, constraints -> + layout(width = constraints.maxWidth, height = constraints.maxHeight) { + val aspectRatio = AspectRatio.from(constraints) + + val gridGroup = FocusGroup( + measurables[0], + measurables[1][0], + ) + val horizontalCluesGroup = FocusGroup( + measurables[2], + measurables[3][0] + ) + val verticalCluesGroup = FocusGroup( + measurables[4], + measurables[5][0] + ) + val timeMeasurable = measurables[6][0] + val dividerMeasurables = measurables[7] + + val spacingPx = spacing.roundToPx() + + when (aspectRatio) { + PORTRAIT -> { + portrait( + constraints, + spacingPx, + gridGroup, + horizontalCluesGroup, + verticalCluesGroup, + timeMeasurable, + dividerMeasurables + ) + } + + SQUARISH -> { + squarish( + constraints, + spacingPx, + gridGroup, + horizontalCluesGroup, + verticalCluesGroup, + timeMeasurable + ) + } + + LANDSCAPE -> { + landscape( + constraints, + spacingPx, + gridGroup, + horizontalCluesGroup, + verticalCluesGroup, + timeMeasurable, + dividerMeasurables[0] + ) + } + } + } + } +} + +internal fun cluesBoxConstraints( + width: Int, + itemConstraints: Constraints, + itemCount: Int +): Constraints = fixed( + width, + itemConstraints.maxHeight * ceil(itemCount.toFloat() / (width / itemConstraints.maxWidth)).toInt() +) + +internal fun Placeable.PlacementScope.placeClues( + placeables: List, + offsetX: Int, + offsetY: Int, + maxWidth: Int +): Int { + if (placeables.isEmpty()) return offsetY + + val itemWidth = placeables.first().width + val itemHeight = placeables.first().height + val columns = max(1, maxWidth / itemWidth) + val spacing = if (columns == 1) 0 else (maxWidth - columns * itemWidth) / (columns - 1) + var currentX = offsetX + var currentY = offsetY + var i = 0 + for (placeable in placeables) { + placeable.place(currentX, currentY) + currentX += itemWidth + spacing + i++ + if (i % columns == 0 && i < placeables.size) { + currentX = offsetX + currentY += itemHeight + } + } + return currentY + itemHeight +} + diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/focus box.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/focus box.kt new file mode 100644 index 0000000..af8410b --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/focus box.kt @@ -0,0 +1,44 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.Color +import ch.dissem.yaep.ui.common.focus.FocusFollowingFocusable + +@Composable +fun FocusBox(focusable: FocusFollowingFocusable, requestFocus: Boolean = false) { + val hasFocus by focusable.hasFocus.collectAsState(false) + Box( + modifier = Modifier + .focusRequester(focusable.focusRequester) + .onFocusEvent { state -> + focusable.setFocus(state) + } + .onFocusChanged {} + .focusable() + .clip(MaterialTheme.shapes.small) + .background( + if (hasFocus) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + Color.Transparent + } + ), + ) + if (requestFocus) { + LaunchedEffect(Unit) { + focusable.focusRequester.requestFocus() + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..aa2adfd --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/landscape.kt @@ -0,0 +1,97 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.ui.layout.Measurable +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.Constraints.Companion.fixedWidth + +internal fun Placeable.PlacementScope.landscape( + constraints: Constraints, + spacingPx: Int, + grid: FocusGroup, + horizontalClues: FocusGroup, + verticalClues: FocusGroup, + timeMeasurable: Measurable, + dividerMeasurable: Measurable +) { + val gridSize = constraints.maxHeight + val gridItemSize = (gridSize - 12 * spacingPx) / 18 + val rightBarWidth = constraints.maxWidth - gridSize - 2 * spacingPx + + val gridConstraints = fixed(gridSize, gridSize) + val baseSpace = gridSize - 2 * spacingPx + val horizontalCluesConstraints = fixed( + width = 3 * gridItemSize + 2 * spacingPx, + height = gridItemSize + 2 * spacingPx + ) + val verticalCluesConstraints = fixed( + width = gridItemSize + 2 * spacingPx, + height = 3 * gridItemSize + 2 * spacingPx + ) + val timeConstraints = Constraints.fixedHeight(baseSpace / 10) + val dividerConstraints = fixedWidth(rightBarWidth - 2 * spacingPx) + + val gridPlaceable = grid.item.measure(gridConstraints) + val gridBoxPlaceable = grid.box.measure(gridConstraints) + val horizontalCluesPlaceables = horizontalClues.items.map { + it.measure(horizontalCluesConstraints) + } + val horizontalCluesBoxPlaceable = horizontalClues.box + .measure( + cluesBoxConstraints( + width = rightBarWidth, + itemConstraints = horizontalCluesConstraints, + itemCount = horizontalClues.count + ) + ) + + val verticalCluesPlaceables = verticalClues.items.map { + it.measure(verticalCluesConstraints) + } + val verticalCluesBoxPlaceable = verticalClues.box + .measure( + cluesBoxConstraints( + width = rightBarWidth, + itemConstraints = verticalCluesConstraints, + itemCount = verticalClues.count + ) + ) + + val timePlaceable = timeMeasurable.measure(timeConstraints) + val dividerPlaceable = dividerMeasurable.measure(dividerConstraints) + + // Position the grid + gridBoxPlaceable.place(0, 0) + gridPlaceable.place(0, 0) + + // Position the horizontal clues4 + val horizontalCluesOffsetX = gridSize + 2 * spacingPx + horizontalCluesBoxPlaceable.place(horizontalCluesOffsetX, 0) + val offsetY = placeClues( + placeables = horizontalCluesPlaceables, + offsetX = horizontalCluesOffsetX, + offsetY = 0, + maxWidth = rightBarWidth + ) + + // 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 time + timePlaceable.place( + x = constraints.maxWidth - timePlaceable.width - spacingPx, + y = constraints.maxHeight - timePlaceable.height + ) +} \ No newline at end of file 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 new file mode 100644 index 0000000..4f6f796 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/portrait.kt @@ -0,0 +1,114 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.ui.layout.Measurable +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.Constraints.Companion.fixedWidth + +internal fun Placeable.PlacementScope.portrait( + constraints: Constraints, + spacingPx: Int, + grid: FocusGroup, + horizontalClues: FocusGroup, + verticalClues: FocusGroup, + timeMeasurable: Measurable, + dividerMeasurables: List +) { + val gridSize = constraints.maxWidth + val gridItemSize = (gridSize - 12 * spacingPx) / 18 + + val gridConstraints = fixed(gridSize, gridSize) + val horizontalCluesConstraints = fixed( + width = 3 * gridItemSize + 2 * spacingPx, + height = gridItemSize + 2 * spacingPx + ) + val verticalCluesConstraints = fixed( + width = gridItemSize + 2 * spacingPx, + height = 3 * gridItemSize + 2 * spacingPx + ) + val timeConstraints = Constraints() + val dividerConstraints = fixedWidth(gridSize) + + val gridPlaceable = grid.item.measure(gridConstraints) + val gridBoxPlaceable = grid.box.measure(gridConstraints) + val horizontalCluesPlaceables = horizontalClues.items.map { + it.measure(horizontalCluesConstraints) + } + val verticalCluesPlaceables = verticalClues.items.map { + it.measure(verticalCluesConstraints) + } + val timePlaceable = timeMeasurable.measure(timeConstraints) + val divider1Placeable = dividerMeasurables[0].measure(dividerConstraints) + val divider2Placeable = dividerMeasurables[1].measure(dividerConstraints) + + val horizontalCluesBoxPlaceable = horizontalClues.box + .measure( + cluesBoxConstraints( + width = gridSize, + itemConstraints = horizontalCluesConstraints, + itemCount = horizontalClues.count + ) + ) + val verticalCluesBoxPlaceable = verticalClues.box + .measure( + cluesBoxConstraints( + width = gridSize, + itemConstraints = verticalCluesConstraints, + itemCount = verticalClues.count + ) + ) + + // Position the grid + gridBoxPlaceable.place(0, 0) + gridPlaceable.place(0, 0) + + divider1Placeable.place(0, gridSize + spacingPx) + + // Position the horizontal clues + val horizontalCluesOffsetY = gridSize + 2 * spacingPx + + horizontalCluesBoxPlaceable.place(0, horizontalCluesOffsetY) + var offsetY = placeClues( + placeables = horizontalCluesPlaceables, + offsetX = 0, + offsetY = horizontalCluesOffsetY, + 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) + + // Position the time + val remainingSpace = constraints.maxHeight - offsetY + if (remainingSpace < timePlaceable.height) { + val scale = remainingSpace.toFloat() / timePlaceable.height.toFloat() + if (scale > 0.1f) { + timePlaceable.placeWithLayer( + x = constraints.maxWidth - timePlaceable.width - spacingPx, + y = constraints.maxHeight - timePlaceable.height + ) { + scaleX = scale + scaleY = scale + translationX = (timePlaceable.width * (1 - scale)) / 2f + translationY = (timePlaceable.height * (1 - scale)) / 2f + } + } + } else { + timePlaceable.place( + x = constraints.maxWidth - timePlaceable.width - spacingPx, + y = constraints.maxHeight - 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 new file mode 100644 index 0000000..4d39dc8 --- /dev/null +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/squarish.kt @@ -0,0 +1,89 @@ +package ch.dissem.yaep.ui.common.layout + +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Constraints.Companion.fixed +import kotlin.math.min + +internal fun Placeable.PlacementScope.squarish( + constraints: Constraints, + spacingPx: Int, + grid: FocusGroup, + horizontalClues: FocusGroup, + verticalClues: FocusGroup, + timeMeasurable: Measurable +) { + val gridSize = (7 * min(constraints.maxWidth, constraints.maxHeight)) / 10 + val gridItemSize = (gridSize - 12 * spacingPx) / 18 + val rightBarWidth = constraints.maxWidth - gridSize - spacingPx + + val gridConstraints = fixed(gridSize, gridSize) + val horizontalCluesConstraints = fixed( + width = 3 * gridItemSize + 2 * spacingPx, + height = gridItemSize + 2 * spacingPx + ) + val verticalCluesConstraints = fixed( + width = gridItemSize + 2 * spacingPx, + height = 3 * gridItemSize + 2 * spacingPx + ) + val timeConstraints = Constraints() + + + val gridPlaceable = grid.item.measure(gridConstraints) + val gridBoxPlaceable = grid.box.measure(gridConstraints) + val horizontalCluesPlaceables = horizontalClues.items.map { + it.measure(horizontalCluesConstraints) + } + val horizontalCluesBoxPlaceable = horizontalClues.box + .measure( + cluesBoxConstraints( + width = rightBarWidth, + itemConstraints = horizontalCluesConstraints, + itemCount = horizontalClues.count + ) + ) + + val verticalCluesPlaceables = verticalClues.items.map { + it.measure(verticalCluesConstraints) + } + val verticalCluesBoxPlaceable = verticalClues.box + .measure( + cluesBoxConstraints( + width = rightBarWidth, + itemConstraints = verticalCluesConstraints, + itemCount = verticalClues.count + ) + ) + val timePlaceable = timeMeasurable.measure(timeConstraints) + + // Position the grid + gridBoxPlaceable.place(0, 0) + gridPlaceable.place(0, 0) + + // Position the horizontal clues + val horizontalCluesOffsetX = gridSize + 2 * spacingPx + horizontalCluesBoxPlaceable.place(horizontalCluesOffsetX, 0) + placeClues( + placeables = horizontalCluesPlaceables, + offsetX = horizontalCluesOffsetX, + offsetY = 0, + maxWidth = rightBarWidth + ) + + // 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( + x = constraints.maxWidth - timePlaceable.width - spacingPx, + y = constraints.maxHeight - timePlaceable.height + ) +}