From f992298da8b2f97068601f6504a3d2cbb9514791 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 07:34:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20console=20UI=20=E2=80=94=20per?= =?UTF-8?q?-controller=20glyphs,=20dialog=20scrolling,=20animated=20forms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hint-bar glyphs now wear the driving controller's family (kit Gamepad.styleFor by USB vendor id → MainActivity.lastPadStyle, kept live by real input like lastPadIsGamepad): PlayStation pads get Canvas-drawn cross/circle/square/triangle shapes in the classic colours, Nintendo pads monochrome lettering, Xbox/Valve/unknown the coloured letter discs. Hint chars stay semantic (KEYCODE_BUTTON names); only the rendering changes. - The Options legend renders the pad's real Select-family button (SelectButtonGlyph): Xbox View windows, PlayStation Create capsule, Nintendo minus — instead of a bare capsule outline. - GamepadDialog: body + action stack scroll together (title pinned) with BringIntoViewRequester keeping the focused button visible — a 5-action host options dialog compressed/clipped its last button in short landscape windows because the pinned stack could not scroll. - Console form polish: shared animateConsoleFocus (bg/border cross-fade + spring scale) across settings rows / add-host fields / action rows; ConsoleSwitch (spring knob, tinting track) replaces On/Off text on toggle rows; choice values slide in the direction they were stepped (AnimatedContent + SizeTransform) with chevrons that fade in place; the focused row's detail unfolds via AnimatedVisibility; dialog buttons and keyboard keycaps cross-fade (keycaps at 90 ms for hold-to-repeat). - Console settings gain the "Low-latency mode" (Video) and "Auto-wake on connect" (Interface) rows, round-tripping with the touch settings. - Screenshot scene: StatsOverlay call updated to the 18-double layout + the new decoderLabel parameter (fixes the android-screenshots CI compile). Co-Authored-By: Claude Fable 5 --- .../io/unom/punktfunk/GamepadAddHostScreen.kt | 38 ++- .../kotlin/io/unom/punktfunk/GamepadChrome.kt | 222 ++++++++++++++++-- .../io/unom/punktfunk/GamepadDialogs.kt | 74 ++++-- .../unom/punktfunk/GamepadSettingsScreen.kt | 99 ++++++-- .../kotlin/io/unom/punktfunk/MainActivity.kt | 15 +- .../unom/punktfunk/screenshots/ShotScenes.kt | 17 +- .../kotlin/io/unom/punktfunk/kit/Gamepad.kt | 23 ++ 7 files changed, 412 insertions(+), 76 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt index e00a2d6c..7da0c8a2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt @@ -2,7 +2,8 @@ package io.unom.punktfunk import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -361,15 +362,15 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row] @Composable private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) { - val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale") + val visuals = animateConsoleFocus(active = focused || editing, editing = editing) val shape = RoundedCornerShape(14.dp) Row( modifier = Modifier .fillMaxWidth() - .graphicsLayer { scaleX = scale; scaleY = scale } + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } .clip(shape) - .background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF)) - .border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) + .background(visuals.background) + .border(1.dp, visuals.border, shape) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, @@ -389,15 +390,20 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () - @Composable private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) { - val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale") + val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) + val labelColor by animateColorAsState( + if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f), + tween(160), + label = "addLabel", + ) Box( modifier = Modifier .fillMaxWidth() - .graphicsLayer { scaleX = scale; scaleY = scale } + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } .clip(shape) - .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) - .border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) + .background(visuals.background) + .border(1.dp, visuals.border, shape) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) .padding(vertical = 14.dp), contentAlignment = Alignment.Center, @@ -406,7 +412,7 @@ private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onCl label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, - color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f), + color = labelColor, ) } } @@ -448,11 +454,19 @@ private fun KeyboardGrid( @Composable private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + // Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the + // trailing key must have faded before the cursor is two keys away — quick, but no longer a snap. + val bg by animateColorAsState( + if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF), + tween(90), + label = "keyBg", + ) + val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg") Box( modifier = modifier .height(if (compact) 34.dp else 44.dp) .clip(RoundedCornerShape(9.dp)) - .background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF)) + .background(bg) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick), contentAlignment = Alignment.Center, ) { @@ -460,7 +474,7 @@ private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, - color = if (focused) Color.Black else Color.White, + color = fg, textAlign = TextAlign.Center, ) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt index 61c89430..e7e4cefb 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt @@ -1,10 +1,14 @@ package io.unom.punktfunk +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -15,6 +19,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -31,20 +36,28 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect +import io.unom.punktfunk.kit.Gamepad import kotlin.math.PI import kotlin.math.cos import kotlin.math.max +import kotlin.math.roundToInt import kotlin.math.sin // The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's @@ -189,9 +202,12 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: } /** - * One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue. - * [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working - * controller can still drive the console UI (and reach Settings to switch it off). + * One glyph + label cell of a hint bar. [glyph] is the SEMANTIC face letter (the Android + * `KEYCODE_BUTTON_*` name — 'A' = confirm/south); [color] its Xbox-convention hue. How the pair is + * actually DRAWN is the hint bar's decision, per the driving controller's [Gamepad.PadStyle] — a + * DualSense renders 'A' as the ✕ shape, a Switch pad as a monochrome letter. [onClick], when set, + * makes the cell tappable — a TOUCH escape hatch so a user without a working controller can still + * drive the console UI (and reach Settings to switch it off). */ class GamepadHint( val glyph: Char, @@ -201,11 +217,16 @@ class GamepadHint( // Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc — // for a TV remote, which has no A/B/X/Y. val select: Boolean = false, - // Render as the gamepad Select/View button (a small capsule). + // Render as the pad's physical Select/View/Create/− button (per PadStyle) — the button that + // delivers KEYCODE_BUTTON_SELECT. val viewButton: Boolean = false, ) -/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */ +/** + * Xbox-convention face-button colours, so the glyphs read at a glance across the room. These are + * the DEFAULT (Xbox/generic) rendering; the hint bar swaps in PlayStation shapes or Nintendo + * monochrome per the driving pad's [Gamepad.PadStyle] at draw time. + */ object PadGlyph { val A = Color(0xFF6BBE45) val B = Color(0xFFD14B4B) @@ -216,6 +237,87 @@ object PadGlyph { ) } +/** The dark button-face fill shared by the PlayStation / Nintendo / select-button badges. */ +internal val PadButtonFace = Color(0xFF2A2740) + +/** The animated focus visuals of one console row/field/button — see [animateConsoleFocus]. */ +class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: Color) + +/** + * The focus visuals every console form element shares (settings rows, add-host fields, action + * rows), ANIMATED: the background/border cross-fade instead of snapping between the focused and + * resting looks, and the scale pops on a soft spring. [editing] draws the brighter violet border + * of a field actively receiving keyboard input. + */ +@Composable +fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals { + val scale by animateFloatAsState( + targetValue = if (active) 1f else 0.98f, + animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), + label = "consoleScale", + ) + val background by animateColorAsState( + if (active) Color(0x336656F2) else Color(0x14FFFFFF), + tween(160), + label = "consoleBg", + ) + val border by animateColorAsState( + when { + editing -> Color(0xB38678F5) + active -> Color.White.copy(alpha = 0.28f) + else -> Color.White.copy(alpha = 0.06f) + }, + tween(160), + label = "consoleBorder", + ) + return ConsoleFocusVisuals(scale, background, border) +} + +/** + * The console-styled switch a toggle row renders in place of an "On"/"Off" value: a brand-violet + * track that tints as it engages while the knob slides across on a spring — the state change reads + * from across the room, and the motion confirms the press. + */ +@Composable +fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) { + val travel by animateFloatAsState( + targetValue = if (on) 1f else 0f, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f), + label = "switchKnob", + ) + val track by animateColorAsState( + if (on) Color(0xFF6656F2) else Color(0x26FFFFFF), + tween(200), + label = "switchTrack", + ) + val outline by animateColorAsState( + Color.White.copy(alpha = if (focused) 0.45f else 0.15f), + tween(160), + label = "switchOutline", + ) + val trackW = 44.dp + val trackH = 24.dp + val pad = 3.dp + val knob = trackH - pad * 2 + Box( + modifier + .size(trackW, trackH) + .clip(RoundedCornerShape(50)) + .background(track) + .border(1.dp, outline, RoundedCornerShape(50)), + contentAlignment = Alignment.CenterStart, + ) { + Box( + Modifier + .padding(horizontal = pad) + .offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) } + .size(knob) + .clip(CircleShape) + .background(Color.White), + ) + } +} + /** A round face-button badge: a coloured disc with the button letter, like a controller's face. */ @Composable fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) { @@ -253,16 +355,94 @@ private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { GamepadButtonGlyph('↩', PadGlyph.B, size) } -/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */ +/** + * A PlayStation face button: the dark button face with the coloured shape outline Sony prints on it. + * Keyed by the SEMANTIC letter (Android keycode name): A = ✕ cross, B = ○ circle, X = □ square, + * Y = △ triangle — exactly how a Sony pad's buttons map to `KEYCODE_BUTTON_*`, in the classic + * DualShock colours. + */ @Composable -private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { - Box(Modifier.size(size), contentAlignment = Alignment.Center) { - Box( - Modifier - .size(width = size * 0.74f, height = size * 0.46f) - .clip(RoundedCornerShape(50)) - .border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)), - ) +internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) { + val color = when (glyph) { + 'A' -> Color(0xFF7C9CE8) // cross — light blue + 'B' -> Color(0xFFE0736F) // circle — red + 'X' -> Color(0xFFD48FC7) // square — pink + else -> Color(0xFF5FBFA5) // triangle — green + } + Box( + Modifier.size(size).clip(CircleShape).background(PadButtonFace), + contentAlignment = Alignment.Center, + ) { + Canvas(Modifier.size(size * 0.46f)) { + val w = this.size.minDimension + val stroke = Stroke(width = w * 0.17f, cap = StrokeCap.Round, join = StrokeJoin.Round) + when (glyph) { + 'A' -> { // ✕ — the two diagonals + drawLine(color, Offset(0f, 0f), Offset(w, w), stroke.width, StrokeCap.Round) + drawLine(color, Offset(w, 0f), Offset(0f, w), stroke.width, StrokeCap.Round) + } + 'B' -> drawCircle(color, radius = (w - stroke.width) / 2f, style = stroke) + 'X' -> drawRect( + color, + topLeft = Offset(stroke.width / 2f, stroke.width / 2f), + size = Size(w - stroke.width, w - stroke.width), + style = stroke, + ) + else -> { // △ + val p = Path().apply { + moveTo(w / 2f, stroke.width / 2f) + lineTo(w - stroke.width / 2f, w - stroke.width / 2f) + lineTo(stroke.width / 2f, w - stroke.width / 2f) + close() + } + drawPath(p, color, style = stroke) + } + } + } + } +} + +/** + * The pad's physical Select-family button — the one that delivers `KEYCODE_BUTTON_SELECT` and opens + * Options — drawn per [Gamepad.PadStyle] as a badge with the button's real face: Xbox View (two + * overlapping windows), PlayStation Create/Share (a slim capsule), Nintendo − (minus). The generic + * fallback wears the capsule too (the near-universal select shape). + */ +@Composable +internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) { + Box( + Modifier.size(size).clip(CircleShape).background(PadButtonFace), + contentAlignment = Alignment.Center, + ) { + when (style) { + Gamepad.PadStyle.XBOX -> Box(Modifier.size(size * 0.50f)) { + // The View icon: two overlapping outlined windows; the front one is filled with the + // button face so it visibly occludes the back one. + val corner = RoundedCornerShape(2.dp) + Box( + Modifier.size(size * 0.32f).align(Alignment.TopEnd) + .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner), + ) + Box( + Modifier.size(size * 0.32f).align(Alignment.BottomStart) + .clip(corner).background(PadButtonFace) + .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner), + ) + } + Gamepad.PadStyle.NINTENDO -> Text( + "−", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = (size.value * 0.62f).sp, + textAlign = TextAlign.Center, + ) + else -> Box( + Modifier + .size(width = size * 0.58f, height = size * 0.30f) + .clip(RoundedCornerShape(50)) + .border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)), + ) + } } } @@ -274,8 +454,12 @@ private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, hazeState: HazeState? = null) { // On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses: // A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the - // home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests). - val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true + // home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters / + // PlayStation shapes / Nintendo monochrome) from the controller that last drove the UI. + // Defaults to the generic gamepad look off an Activity (preview/tests). + val activity = LocalContext.current as? MainActivity + val padIsGamepad = activity?.lastPadIsGamepad ?: true + val padStyle = activity?.lastPadStyle ?: Gamepad.PadStyle.GENERIC val shape = RoundedCornerShape(50) // With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent // scrim below) + a light tint; otherwise fall back to a solid frosted fill. @@ -300,9 +484,13 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze } Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) { when { - h.viewButton -> ViewButtonGlyph() + h.viewButton -> SelectButtonGlyph(padStyle) h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph() !padIsGamepad && h.glyph == 'B' -> BackGlyph() + padStyle == Gamepad.PadStyle.PLAYSTATION && h.glyph in "ABXY" -> + PsFaceGlyph(h.glyph) + padStyle == Gamepad.PadStyle.NINTENDO && h.glyph in "ABXY" -> + GamepadButtonGlyph(h.glyph, PadButtonFace) else -> GamepadButtonGlyph(h.glyph, h.color) } Spacer(Modifier.width(6.dp)) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index 0f87d2c3..5d11b84f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -2,7 +2,12 @@ package io.unom.punktfunk import android.os.Build import androidx.activity.compose.BackHandler +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -19,6 +24,8 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -26,6 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf @@ -90,8 +98,11 @@ fun GamepadDialog( }, onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() }, ) - // Cap the card to most of the screen and let the BODY scroll — in a short landscape window the - // title + body + buttons would otherwise overflow and compress/clip the bottom button. + // Cap the card to most of the screen and let body + BUTTONS scroll together — in a short + // landscape window a 5-action stack (host options) exceeds the card even with an empty body, and + // a pinned actions column can only compress/clip its last button. Only the title stays pinned; + // the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows + // the current action even when the stack scrolls. val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp Box( Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), @@ -109,43 +120,66 @@ fun GamepadDialog( verticalArrangement = Arrangement.spacedBy(14.dp), ) { Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White) - // The body scrolls; the title above and the buttons below stay pinned + always visible. Column( Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp), ) { body() - } - Spacer(Modifier.size(4.dp)) - actions.forEachIndexed { i, a -> - DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick) + Spacer(Modifier.size(4.dp)) + actions.forEachIndexed { i, a -> + DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick) + } } } } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) { - val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale") + val scale by animateFloatAsState( + if (focused) 1.02f else 1f, + spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), + label = "btnScale", + ) + // The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button + // that's scrolled out of a short window, pull it into view (no-op when already visible). + val intoView = remember { BringIntoViewRequester() } + LaunchedEffect(focused) { if (focused) intoView.bringIntoView() } val shape = RoundedCornerShape(14.dp) - val bg = when { - focused -> Color(0xFF6656F2) - primary -> Color(0x336656F2) - else -> Color(0x14FFFFFF) - } - val fg = when { - !enabled -> Color.White.copy(alpha = 0.35f) - focused -> Color.White - primary -> Color(0xFF8678F5) - else -> Color.White.copy(alpha = 0.85f) - } + // Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping. + val bg by animateColorAsState( + when { + focused -> Color(0xFF6656F2) + primary -> Color(0x336656F2) + else -> Color(0x14FFFFFF) + }, + tween(160), + label = "btnBg", + ) + val fg by animateColorAsState( + when { + !enabled -> Color.White.copy(alpha = 0.35f) + focused -> Color.White + primary -> Color(0xFF8678F5) + else -> Color.White.copy(alpha = 0.85f) + }, + tween(160), + label = "btnFg", + ) + val borderColor by animateColorAsState( + Color.White.copy(alpha = if (focused) 0.3f else 0.08f), + tween(160), + label = "btnBorder", + ) Box( modifier = Modifier .fillMaxWidth() + .bringIntoViewRequester(intoView) .graphicsLayer { scaleX = scale; scaleY = scale } .clip(shape) .background(bg) - .border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape) + .border(1.dp, borderColor, shape) .clickable( enabled = enabled, interactionSource = remember { MutableInteractionSource() }, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 8ef08b12..85a548fa 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -2,7 +2,19 @@ package io.unom.punktfunk import android.content.res.Configuration import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -57,6 +69,7 @@ private class GpRow( val detail: String, val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed val activate: () -> Unit, // A → cycle forward (wrapping) / flip + val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text) ) @Composable @@ -72,6 +85,9 @@ fun GamepadSettingsScreen( val rows = buildSettingsRows(s, ::update) var focus by remember { mutableIntStateOf(0) } if (focus > rows.lastIndex) focus = rows.lastIndex + // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the + // value text slides in its AnimatedContent, so the motion matches the button press. + var adjustDir by remember { mutableIntStateOf(1) } val listState = rememberLazyListState() val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE @@ -83,11 +99,11 @@ fun GamepadSettingsScreen( when (dir) { NavDir.UP -> if (focus > 0) focus-- NavDir.DOWN -> if (focus < rows.lastIndex) focus++ - NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1) - NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1) + NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) } + NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) } } }, - onActivate = { rows.getOrNull(focus)?.activate() }, + onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() }, ) // Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the // screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it. @@ -121,8 +137,8 @@ fun GamepadSettingsScreen( ConsoleHeader("Settings", horizontalInset = false) } itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> - SettingRowView(row, focused = index == focus, onClick = { - if (focus == index) row.activate() else focus = index + SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = { + if (focus == index) { adjustDir = 1; row.activate() } else focus = index }) } } @@ -150,9 +166,17 @@ fun GamepadSettingsScreen( } @Composable -private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) { - val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale") +private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) { + val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) + // The chevrons keep their layout slot and only fade, so the value never jumps sideways when + // focus arrives; the value colour cross-fades with them. + val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons") + val valueColor by animateColorAsState( + Color.White.copy(alpha = if (focused) 1f else 0.6f), + tween(160), + label = "valueColor", + ) Column { if (row.header != null) { Text( @@ -166,10 +190,10 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() - .graphicsLayer { scaleX = scale; scaleY = scale } + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } .clip(shape) - .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) - .border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape) + .background(visuals.background) + .border(1.dp, visuals.border, shape) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -186,19 +210,41 @@ private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) { maxLines = 1, ) Spacer(Modifier.weight(1f)) - if (focused) Text("‹ ", color = Color.White.copy(alpha = 0.6f)) - Text( - row.value, - style = MaterialTheme.typography.bodyMedium, - color = if (focused) Color.White else Color.White.copy(alpha = 0.6f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (focused) Text(" ›", color = Color.White.copy(alpha = 0.6f)) + if (row.toggled != null) { + // A toggle is a switch, not text — the sliding knob + tinting track IS the value. + ConsoleSwitch(on = row.toggled, focused = focused) + } else { + Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + // The value slides in the direction it was stepped and its width animates, so + // cycling a choice reads as motion through a list rather than a text swap. + AnimatedContent( + targetState = row.value, + transitionSpec = { + val dir = adjustDir + (slideInHorizontally(tween(180)) { w -> w / 2 * dir } + fadeIn(tween(180))) togetherWith + (slideOutHorizontally(tween(140)) { w -> -w / 2 * dir } + fadeOut(tween(100))) using + SizeTransform(clip = false) + }, + label = "value", + ) { value -> + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = valueColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text(" ›", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + } } // The focused row carries its own one-line description — no dedicated (space-eating) - // detail strip. It appears right where you're looking, and the row grows to fit. - if (focused && row.detail.isNotBlank()) { + // detail strip. It unfolds right where you're looking, and the row grows to fit. + AnimatedVisibility( + visible = focused && row.detail.isNotBlank(), + enter = fadeIn(tween(180, delayMillis = 60)) + expandVertically(tween(180)), + exit = fadeOut(tween(90)) + shrinkVertically(tween(150)), + ) { Text( row.detail, style = MaterialTheme.typography.bodySmall, @@ -245,6 +291,7 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List val target = delta > 0; if (value != target) { write(target); true } else false }, activate = { write(!value) }, + toggled = value, ) return listOf( @@ -278,6 +325,11 @@ private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List Unit): List PadStyle.PLAYSTATION + VID_MICROSOFT, VID_VALVE -> PadStyle.XBOX + VID_NINTENDO -> PadStyle.NINTENDO + else -> PadStyle.GENERIC + } + /** True when [dev]'s source classes include gamepad or joystick. */ fun isPad(dev: InputDevice?): Boolean { val s = dev?.sources ?: return false