diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index ea1bad720..ae962bb76 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher +import androidx.compose.foundation.layout.fillMaxSize import android.Manifest import android.app.ActivityOptions import android.content.Intent @@ -317,6 +318,7 @@ class MainActivity : ComponentActivity() { updates.start() setContent { DroidDeckTheme(theme) { + com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { val sm = settingsMode val page: (@Composable () -> Unit)? = when { sm != null -> { { ModeSettingsHost(sm) } } @@ -566,6 +568,7 @@ class MainActivity : ComponentActivity() { } } } + } if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 2023616d3..1c58332a7 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher +import androidx.compose.foundation.layout.fillMaxSize import android.hardware.input.InputManager import android.hardware.display.DisplayManager import android.content.Intent @@ -315,6 +316,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { isFocusableInTouchMode = true setContent { DroidDeckTheme { + com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) { CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, cursorImage, cursorHotX, cursorHotY, cursorImageScale) if (hud.text.isNotEmpty()) HudText(hud.text) @@ -445,6 +447,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } } + } } private fun refreshHomeApp() { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index d0b4a4fa6..566f66b4e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -304,7 +304,7 @@ internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fa contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)) .background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.dp)) + .glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick), ) { @@ -327,7 +327,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = contentAlignment = Alignment.Center, modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp)) .background(if (hot) pal.signal else colors.surfaceVariant) - .border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp)) + .glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) .padding(horizontal = 14.dp, vertical = 8.dp), @@ -369,7 +369,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier) - .border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE) + .glideBorder(hot, ROW_SHAPE, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect) .controllerConfirm(onClick = onSelect) .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), @@ -398,7 +398,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on contentAlignment = Alignment.Center, modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE) .background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent) - .border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE) + .glideBorder(delHot, ROW_SHAPE, colors.error) .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete) .controllerConfirm(onClick = onDelete), ) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt index df76a1e81..6a8809088 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DriverPage.kt @@ -92,7 +92,7 @@ fun DriverPage( contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)) .background(if (refreshHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(if (refreshHot) 2.dp else 1.dp, if (refreshHot) pal.signal else pal.line, RoundedCornerShape(10.dp)) + .glideBorder(refreshHot, RoundedCornerShape(10.dp), pal.signal, pal.line) .focusRequester(refreshFocus) .hoverable(refreshSrc) .clickable(interactionSource = refreshSrc, indication = null, enabled = !checking, onClick = onRefresh), @@ -173,7 +173,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(3.dp).clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent) - .border(2.dp, if (hot) pal.signal else Color.Transparent, shape), + .glideBorder(hot, shape, pal.signal), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -209,7 +209,7 @@ private fun InstalledRow(row: DriverRow, selected: Boolean, onSelect: () -> Unit contentAlignment = Alignment.Center, modifier = Modifier.padding(end = 8.dp).size(40.dp).clip(RoundedCornerShape(10.dp)) .background(if (delHot) colors.error.copy(alpha = 0.16f) else Color.Transparent) - .border(2.dp, if (delHot) colors.error else Color.Transparent, RoundedCornerShape(10.dp)) + .glideBorder(delHot, RoundedCornerShape(10.dp), colors.error) .focusRequester(delFocus) .focusProperties { left = mainFocus } .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete), @@ -229,7 +229,7 @@ internal fun FocusText(text: String, color: Color, modifier: Modifier = Modifier text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = color, modifier = modifier.clip(shape) .background(if (hot) color.copy(alpha = 0.14f) else Color.Transparent) - .border(2.dp, if (hot) color else Color.Transparent, shape) + .glideBorder(hot, shape, color) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) .padding(horizontal = 12.dp, vertical = 8.dp), ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt new file mode 100644 index 000000000..6779032e1 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt @@ -0,0 +1,253 @@ +package com.droiddeck.launcher.ui + +import android.view.View +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlin.math.abs + +// One focus ring for a whole screen that slides from control to control. When focus moves, the +// edge on the side it is heading springs ahead and the trailing edge catches up a beat later, so +// the ring stretches like a drop and snaps into the new control's shape. + +/** A control that can show the ring: where it is and what its ring looks like there. */ +internal class GlideSource { + var coords: LayoutCoordinates? = null + var shape: Shape by mutableStateOf(androidx.compose.ui.graphics.RectangleShape) + var color by mutableStateOf(Color.Transparent) + var width by mutableStateOf(2.dp) +} + +internal class FocusGlide(val view: View) { + var host: LayoutCoordinates? = null + /** Controls that are hot, the latest last: focus and a hovering pointer can each hold one. */ + val hot = mutableStateListOf() + /** Bumped when the current control moves (a scroll, a relayout). */ + var moves by mutableIntStateOf(0) + + val current: GlideSource? get() = hot.lastOrNull() + + fun claim(s: GlideSource) { hot.remove(s); hot.add(s) } + fun release(s: GlideSource) { hot.remove(s) } + fun moved(s: GlideSource) { if (hot.lastOrNull() === s) moves++ } + + /** [s]'s bounds in the host, cut to what is on screen; null when it is not laid out or is scrolled away. */ + fun boundsOf(s: GlideSource): Rect? { + val h = host ?: return null + val c = s.coords ?: return null + if (!h.isAttached || !c.isAttached) return null + val b = h.localBoundingBoxOf(c, clipBounds = true) + return if (b.width < 1f || b.height < 1f) null else b + } +} + +internal val LocalFocusGlide = staticCompositionLocalOf { null } + +private fun cornerOf(shape: Shape, size: Size, dir: LayoutDirection, density: Density): Float = + when (val o = shape.createOutline(size, dir, density)) { + is Outline.Rounded -> o.roundRect.topLeftCornerRadius.x + is Outline.Rectangle -> 0f + is Outline.Generic -> minOf(size.width, size.height) / 2f + } + +/** + * A control's outline: [restColor] at [restWidth] always, and [hotColor] at [hotWidth] while [hot]. + * Under a [FocusGlideHost] the hot outline is the host's one sliding ring; elsewhere (a dialog, a + * popup, a screen with no host) it is drawn here, as a plain border. + */ +@Composable +internal fun Modifier.glideBorder( + hot: Boolean, + shape: Shape, + hotColor: Color, + restColor: Color = Color.Transparent, + hotWidth: Dp = 2.dp, + restWidth: Dp = 1.dp, +): Modifier { + val glide = LocalFocusGlide.current + if (glide == null || glide.view !== LocalView.current) { + return border(if (hot) hotWidth else restWidth, if (hot) hotColor else restColor, shape) + } + val src = remember { GlideSource() } + src.shape = shape + src.color = hotColor + src.width = hotWidth + DisposableEffect(glide, hot) { + if (hot) glide.claim(src) + onDispose { glide.release(src) } + } + val base = if (restColor.alpha > 0f) border(restWidth, restColor, shape) else this + return base.onGloballyPositioned { src.coords = it; glide.moved(src) } +} + +/** Leading edge on a focus move: quick and a little loose. */ +private fun lead(): AnimationSpec = Motion.sp(0.62f, 700f) +/** Trailing edge: slower, settles without a bounce to speak of. */ +private fun trail(): AnimationSpec = Motion.sp(0.78f, 360f) +/** Following a control that moves under the ring (a scroll): tight, no wobble. */ +private fun follow(): AnimationSpec = Motion.sp(1f, 2400f) + +/** How long after a focus move the ring keeps its stretchy springs and keeps checking where it is. */ +private const val SETTLE_MS = 450L + +/** + * Hosts the sliding focus ring for everything inside it. The ring is drawn over [content], in the + * host's space, so put the host inside anything that moves as a whole (a sheet that slides in). + */ +@Composable +internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + val view = LocalView.current + val density = LocalDensity.current + val dir = LocalLayoutDirection.current + val glide = remember(view) { FocusGlide(view) } + val edges = remember { List(4) { Animatable(0f) } } // left, top, right, bottom + val corner = remember { Animatable(0f) } + val alpha = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + val jobs = remember { arrayOfNulls(5) } + val cur = glide.current + var lastColor by remember { mutableStateOf(Color.Transparent) } + if (cur != null) lastColor = cur.color + val color by animateColorAsState(cur?.color ?: lastColor, Motion.tw(160), label = "glideColor") + var width by remember { mutableStateOf(2.dp) } + if (cur != null) width = cur.width + + LaunchedEffect(glide) { + var shown: GlideSource? = null + var movedAt = 0L + var dirSpecs: List> = List(4) { follow() } + var delays = LongArray(4) + fun go(i: Int, v: Float, spec: AnimationSpec, wait: Long) { + jobs[i]?.cancel() + jobs[i] = scope.launch { + if (wait > 0) delay(Motion.ms(wait.toInt()).toLong()) + edges[i].animateTo(v, spec) + } + } + fun cornerTo(s: GlideSource, b: Rect, snap: Boolean) { + val size = s.coords?.size?.let { Size(it.width.toFloat(), it.height.toFloat()) } ?: b.size + val r = cornerOf(s.shape, size, dir, density) + jobs[4]?.cancel() + jobs[4] = scope.launch { if (snap) corner.snapTo(r) else corner.animateTo(r, Motion.sp(0.8f, 500f)) } + } + snapshotFlow { glide.current to glide.moves }.collectLatest { (src, _) -> + if (src == null) { + // Focus passing between two controls can let go of one a moment before the next + // takes it: wait a beat, so that reads as a move and not a fade out and back in. + delay(60) + scope.launch { alpha.animateTo(0f, Motion.tw(140)) } + shown = null + return@collectLatest + } + var first = src !== shown + val start = System.nanoTime() + var last: Rect? = null + while (true) { + val b = glide.boundsOf(src) + if (b == null) { + if (first) scope.launch { alpha.animateTo(0f, Motion.tw(140)) } + } else if (first) { + val prev = shown + val wasShowing = prev != null && alpha.targetValue > 0f + shown = src + first = false + if (!wasShowing) { + // Appearing: a hair bigger, pulled in onto the control as it fades up. + val out = with(density) { 3.dp.toPx() } + val grown = listOf(b.left - out, b.top - out, b.right + out, b.bottom + out) + jobs.forEach { it?.cancel() } + scope.launch { edges.forEachIndexed { i, e -> e.snapTo(grown[i]) } }.join() + dirSpecs = List(4) { Motion.sp(0.6f, 700f) } + delays = LongArray(4) + cornerTo(src, b, snap = true) + scope.launch { alpha.animateTo(1f, Motion.tw(140)) } + } else { + // Moving: the edges on the side it is heading lead. + val dx = b.center.x - (edges[0].value + edges[2].value) / 2f + val dy = b.center.y - (edges[1].value + edges[3].value) / 2f + val horizontal = abs(dx) >= abs(dy) + val leadIdx = if (horizontal) (if (dx >= 0) 2 else 0) else (if (dy >= 0) 3 else 1) + val trailIdx = (leadIdx + 2) % 4 + dirSpecs = List(4) { i -> when (i) { leadIdx -> lead(); trailIdx -> trail(); else -> Motion.sp(0.7f, 520f) } } + delays = LongArray(4) { i -> if (i == trailIdx) 30L else 0L } + cornerTo(src, b, snap = false) + scope.launch { alpha.animateTo(1f, Motion.tw(120)) } + } + movedAt = System.nanoTime() + listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, dirSpecs[i], delays[i]) } + last = b + } else if (b != last) { + // It moved under the ring: keep the stretch while the move is fresh, else follow. + val fresh = (System.nanoTime() - movedAt) / 1_000_000 < SETTLE_MS + listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> go(i, v, if (fresh) dirSpecs[i] else follow(), 0L) } + if (last == null) scope.launch { alpha.animateTo(1f, Motion.tw(120)) } + last = b + } + // Layer animations (a tile growing, a row sliding) move it without a relayout, so + // look again each frame for a moment after anything changes. + if ((System.nanoTime() - start) / 1_000_000 > SETTLE_MS) break + withFrameNanos { } + } + } + } + + Box( + modifier + .onGloballyPositioned { glide.host = it } + .drawWithContent { + drawContent() + val a = alpha.value + if (a <= 0.01f) return@drawWithContent + val w = width.toPx() + val l = edges[0].value; val t = edges[1].value; val r = edges[2].value; val b = edges[3].value + val sw = (r - l - w).coerceAtLeast(0f) + val sh = (b - t - w).coerceAtLeast(0f) + val rad = (corner.value - w / 2f).coerceIn(0f, minOf(sw, sh) / 2f) + drawRoundRect( + color, topLeft = Offset(l + w / 2f, t + w / 2f), size = Size(sw, sh), + cornerRadius = CornerRadius(rad), style = Stroke(w), alpha = a, + ) + }, + ) { + CompositionLocalProvider(LocalFocusGlide provides glide) { content() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt index a49df3698..64e1bfc60 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt @@ -118,13 +118,12 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera val lift by animateFloatAsState(if (hot) -5f else 0f, Motion.sp(0.6f), label = "tileLift") val elev by animateFloatAsState(if (hot) 18f else 2f, Motion.tw(300), label = "tileElev") val pal = LocalPalette.current - val ring by animateColorAsState(if (hot) pal.signal else Color.Transparent, Motion.tw(220), label = "tileRing") Column( modifier = track .graphicsLayer { scaleX = scale; scaleY = scale; translationY = lift.dp.toPx(); shadowElevation = elev.dp.toPx(); shape = Shape12; clip = false; ambientShadowColor = if (hot) pal.signal else Color.Black; spotShadowColor = if (hot) pal.signal else Color.Black; transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.9f) } .clip(Shape12) .background(colors.surface) - .border(2.5.dp, ring, Shape12) + .glideBorder(hot, Shape12, pal.signal, hotWidth = 2.5.dp) .alpha(if (t.dim && !hot) 0.55f else 1f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 400c4b4b3..ccfe357ce 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -403,7 +403,7 @@ private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boole .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else if (e.installed) colors.surface else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .padding(horizontal = 12.dp, vertical = 10.dp), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index 3c279aa87..ea67f9cd7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -220,7 +220,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni modifier = Modifier.fillMaxWidth().paneItem("game:${g.appId}") .clip(Shape12) .background(if (selected) pal.signal.copy(alpha = 0.14f) else if (hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent) - .border(2.dp, if (selected || focused) pal.signal else Color.Transparent, Shape12) + .glideBorder(focused, Shape12, pal.signal, if (selected) pal.signal else Color.Transparent, restWidth = 2.dp) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onSelect) .controllerConfirm(onClick = onLaunch) .padding(horizontal = 10.dp, vertical = 6.dp), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index e1cd31996..18630a028 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -201,7 +201,6 @@ private fun RailItem( if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent, Motion.tw(220), label = "railFill", ) - val ring by animateColorAsState(if (focused) pal.signal else Color.Transparent, Motion.tw(180), label = "railRing") val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale") Box( contentAlignment = Alignment.Center, @@ -211,7 +210,7 @@ private fun RailItem( .size(width = if (iconOnly) 52.dp else 80.dp, height = height) .clip(Shape14) .background(fill) - .border(2.dp, ring, Shape14) + .glideBorder(focused, Shape14, pal.signal) .hoverable(src) .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) .then(if (iconOnly) Modifier.semantics { contentDescription = label } else Modifier), @@ -248,7 +247,7 @@ private fun ResumeRailItem(name: String, compact: Boolean, iconOnly: Boolean, on .width(if (iconOnly) 52.dp else 80.dp) .clip(Shape14) .background(pal.good.copy(alpha = if (focused || hovered) 0.20f else 0.12f)) - .border(2.dp, if (focused) pal.signal else Color.Transparent, Shape14) + .glideBorder(focused, Shape14, pal.signal) .hoverable(src) .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onResume) .semantics { contentDescription = "Resume $name" } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 048a86aac..bc51f4036 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -370,7 +370,7 @@ private fun ToolCard(t: ToolSpec, modifier: Modifier) { .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick) .controllerConfirm(onClick = t.onClick) .padding(horizontal = 14.dp, vertical = 12.dp), @@ -425,14 +425,13 @@ private fun SettingCard(label: String, value: String, id: String, modifier: Modi val hot = rememberHot(src) val pressed by src.collectIsPressedAsState() val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale") - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(220), label = "cardEdge") Column( verticalArrangement = Arrangement.spacedBy(3.dp), modifier = modifier.paneItem(id) .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, edge, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 14.dp, vertical = 12.dp), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index 7d034a138..8f1f8b3e8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -116,7 +116,7 @@ internal fun BackLink(label: String, compact: Boolean = false, onClick: () -> Un modifier = Modifier.paneItem("back:$label").heightIn(min = if (compact) 44.dp else 48.dp) .clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f)) - .border(2.dp, if (hot) pal.signal else Color.Transparent, shape) + .glideBorder(hot, shape, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(start = 8.dp, end = 16.dp), @@ -180,7 +180,7 @@ internal fun PrimaryButton( .shine(hot, 0.45f) // The grow and shine alone barely show on the light fill: outline it when a // controller is on it, as the other controls are. - .border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12) + .glideBorder(hot, Shape12, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled) { placed[0]?.takeIf { it.isAttached }?.let { LaunchOrigin.mark(it.boundsInRoot(), placed) } onClick() @@ -209,11 +209,10 @@ internal fun SecondaryButton(text: String, enabled: Boolean = true, compact: Boo val pressed by src.collectIsPressedAsState() val scale by animateFloatAsState(if (pressed) 0.955f else if (hot) 1.02f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "secScale") val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "secEdge") val fill by animateColorAsState(if (hot) pal.signal.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.03f), Motion.tw(250), label = "secFill") Box( contentAlignment = Alignment.Center, - modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).border(1.dp, edge, Shape12) + modifier = modifier.paneItem("btn:$text").downToFirstTile().heightIn(min = if (compact) 44.dp else 48.dp).graphicsLayer { scaleX = scale; scaleY = scale }.clip(Shape12).background(fill).glideBorder(hot, Shape12, pal.signal, pal.line2) .alpha(if (enabled) 1f else 0.5f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) @@ -228,10 +227,9 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) val hot = rememberHot(src) val rot by animateFloatAsState(if (hot) 90f else 0f, Motion.sp(0.55f), label = "cog") val pal = LocalPalette.current - val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "cogEdge") Box( contentAlignment = Alignment.Center, - modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, if (size > 48.dp) Shape14 else Shape12) + modifier = Modifier.paneItem("cog").downToFirstTile().size(size).clip(if (size > 48.dp) Shape14 else Shape12).background(Color.White.copy(alpha = 0.03f)).glideBorder(hot, if (size > 48.dp) Shape14 else Shape12, pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), ) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt index ad3b34405..3f7a03558 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -202,12 +202,11 @@ private fun RowScope.KeyCap( else -> Color.White.copy(alpha = 0.07f) }, label = "keyFill", ) - val edge = if (focused) pal.signal else if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f) Box( contentAlignment = Alignment.Center, modifier = modifier.weight(weight).fillMaxHeight() .background(fill, RoundedCornerShape(7.dp)) - .border(if (focused) 2.dp else 1.dp, edge, RoundedCornerShape(7.dp)) + .glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, if (lit) pal.signal.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.12f)) .pointerInput(Unit) { awaitEachGesture { awaitFirstDown().consume() @@ -244,7 +243,7 @@ private fun HeaderButton(text: String, onClick: () -> Unit) { contentAlignment = Alignment.Center, modifier = Modifier.fillMaxHeight() .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp)) - .border(if (focused) 2.dp else 1.dp, if (focused) pal.signal else Color.White.copy(alpha = 0.14f), RoundedCornerShape(7.dp)) + .glideBorder(focused, RoundedCornerShape(7.dp), pal.signal, Color.White.copy(alpha = 0.14f)) .clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) .padding(horizontal = 12.dp), ) { Text(text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = Color.White) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index f02b05134..501dd69b9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -1,6 +1,28 @@ package com.droiddeck.launcher.ui import androidx.compose.ui.draw.alpha +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlin.math.roundToInt +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.offset @@ -42,9 +64,7 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -195,6 +215,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha var androidAppsExpanded by rememberSaveable { mutableStateOf(false) } var appToChooseDisplay by remember { mutableStateOf(null) } var confirmStop by remember { mutableStateOf(false) } + var sheetCoords by remember { mutableStateOf(null) } + var stopCoords by remember { mutableStateOf(null) } val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } } val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil") val focus = remember { DrawerFocus() } @@ -240,10 +262,10 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)), exit = slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)), ) { + FocusGlideHost(Modifier.fillMaxHeight().width(sheetWidth)) { Box(Modifier.fillMaxSize().onGloballyPositioned { sheetCoords = it }) { Column( modifier = Modifier - .fillMaxHeight() - .width(sheetWidth) + .fillMaxSize() .background(pal.background.copy(alpha = 0.97f)) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} .focusGroup() @@ -255,7 +277,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = if (short) 40.dp else 44.dp)) { Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - StopSessionButton(modifier = focus.track(page, "stop")) { host.open = null; confirmStop = true } + StopSessionButton(modifier = focus.track(page, "stop").onGloballyPositioned { stopCoords = it }) { host.open = null; confirmStop = true } } if (a.onSteamMenu != null && a.onQam != null) { val qamInteraction = remember { MutableInteractionSource() } @@ -437,6 +459,18 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } } } } + StopConfirm( + open = confirmStop, + pill = sheetCoords?.let { sc -> stopCoords?.takeIf { it.isAttached && sc.isAttached }?.let { sc.localBoundingBoxOf(it, clipBounds = false) } }, + controllerActive = controllerActive, + onCancel = { confirmStop = false }, + onStop = { onScreen -> + confirmStop = false + val from = a.onStopFrom + if (from != null) from(onScreen) else a.onStop() + }, + ) + } } } } @@ -457,44 +491,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha onDismiss = { appToChooseDisplay = null }, ) } - if (confirmStop) { - val cancelFocus = remember { FocusRequester() } - val cancel = { confirmStop = false } - // Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken - // against the screen and the session's overlay converts them back. - var stopOnScreen by remember { mutableStateOf(null) } - val stop = { - confirmStop = false - val from = a.onStopFrom - if (from != null) from(stopOnScreen) else a.onStop() - } - LaunchedEffect(controllerActive) { - if (controllerActive) { - androidx.compose.runtime.withFrameNanos { } - runCatching { cancelFocus.requestFocus() } - } - } - AlertDialog( - onDismissRequest = cancel, - modifier = Modifier.controllerBack(onBack = cancel), - title = { Text("Stop session?") }, - confirmButton = { - val dialogView = LocalView.current - TextButton( - onClick = stop, - modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c -> - val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) } - stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat()) - }, - colors = ButtonDefaults.textButtonColors(contentColor = colors.error), - ) { Text("Stop") } - }, - dismissButton = { - TextButton(onClick = cancel, modifier = Modifier.focusRequester(cancelFocus) - .controllerConfirm(onClick = cancel)) { Text("Cancel") } - }, - ) - } } @Composable @@ -524,7 +520,7 @@ private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier.heightIn(min = 44.dp).semantics { contentDescription = "Stop session" } - .clip(shape).background(fill).border(if (hot) 2.dp else 1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.55f), shape) + .clip(shape).background(fill).glideBorder(hot, shape, colors.error.copy(alpha = 0.9f), colors.error.copy(alpha = 0.55f)) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(start = 12.dp, end = 14.dp), @@ -549,7 +545,7 @@ private fun QuickAction( val shape = RoundedCornerShape(12.dp) val tile = modifier.height(if (compact) 44.dp else 64.dp).clip(shape) .background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape) + .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(interactionSource) .clickable(interactionSource = interactionSource, indication = LocalIndication.current, onClick = onClick) .controllerConfirm(onClick = onConfirm ?: onClick) @@ -580,7 +576,7 @@ private fun DrawerBumper(label: String, description: String, modifier: Modifier Box( contentAlignment = Alignment.Center, modifier = modifier.size(44.dp).clip(ring) - .border(2.dp, if (hot) pal.signal else Color.Transparent, ring) + .glideBorder(hot, ring, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onClick) .controllerConfirm(onClick = onClick) .semantics { contentDescription = description }, @@ -639,7 +635,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo listOf(pal.signal.copy(alpha = 0.34f * glow), pal.signal.copy(alpha = 0.10f * glow), Color.Transparent), ), ) - .border(if (focused) 2.dp else 0.dp, if (focused) colors.onBackground else Color.Transparent, RoundedCornerShape(16.dp)) + .glideBorder(focused, RoundedCornerShape(16.dp), colors.onBackground) .semantics { contentDescription = "${drawerPageTitles[index]} page" } .hoverable(source) .clickable(interactionSource = source, indication = LocalIndication.current, onClick = select) @@ -788,3 +784,220 @@ private const val DRAWER_TAB_LEAN_DP = 12f private const val DRAWER_TAB_SIDE_SCALE = 0.5f private const val DRAWER_TAB_SIDE_ALPHA = 0.7f + +/** Room between the Stop pill and the box that steps out under it: the pull. */ +private val StepPull = 6.dp +private val StepBoxCorner = 16.dp +private val StepFillet = 10.dp +private val StepBoxMaxWidth = 264.dp + +/** + * The stair-step outline: the pill [p] on top, a box under it from [left] to the pill's right edge + * and down to [bottom], the pull between them [pullTop] (the box's top). Right edges are flush and + * the inside corner where the pull meets the box is rounded the other way. While the box is still + * no wider than the pill it is just the pill, stretched down. + */ +private fun stepPath(path: Path, p: Rect, left: Float, bottom: Float, pullTop: Float, box: Float, fillet: Float) { + path.reset() + val r = p.right; val tl = p.left; val tt = p.top + val pb = maxOf(bottom, p.bottom) + val l = minOf(left, tl) + val pt = minOf(pullTop, pb) + val sw = tl - l + val h = (pb - pt).coerceAtLeast(0f) + val rt = minOf(p.height / 2f, p.width / 2f) + val rbr = minOf(box, (pb - tt) / 2f) + val rbl = minOf(lerp(minOf(box, (pb - tt) / 2f), minOf(box, h / 2f), (sw / box).coerceIn(0f, 1f)), (r - l) / 2f) + val rpl = minOf(box, sw / 2f, h / 2f) + val f = minOf(fillet, sw / 2f) + val yl = minOf(pt + rpl, pb - rbl) + fun corner(rect: Rect, start: Float, sweep: Float, endX: Float, endY: Float) { + if (rect.width < 0.5f) path.lineTo(endX, endY) else path.arcTo(rect, start, sweep, false) + } + path.moveTo(tl + rt, tt) + path.lineTo(r - rt, tt) + corner(Rect(r - 2 * rt, tt, r, tt + 2 * rt), -90f, 90f, r, tt + rt) + path.lineTo(r, pb - rbr) + corner(Rect(r - 2 * rbr, pb - 2 * rbr, r, pb), 0f, 90f, r - rbr, pb) + path.lineTo(l + rbl, pb) + corner(Rect(l, pb - 2 * rbl, l + 2 * rbl, pb), 90f, 90f, l, pb - rbl) + path.lineTo(l, yl) + corner(Rect(l, pt, l + 2 * rpl, pt + 2 * rpl), 180f, 90f, l + rpl, pt) + path.lineTo(tl - f, pt) + corner(Rect(tl - 2 * f, pt - 2 * f, tl, pt), 90f, -90f, tl, pt - f) + path.lineTo(tl, tt + rt) + corner(Rect(tl, tt, tl + 2 * rt, tt + 2 * rt), 180f, 90f, tl + rt, tt) + path.close() +} + +/** + * Stop's confirm, grown out of the Stop pill at [pill] (px, in the sheet): a pull the pill's width + * drops out of it and a wider box steps out to the left under it for the choices, right edges + * flush, so Stop lands under the pill. Cancel, B and a tap outside fold it back into the pill, + * width first. [onStop] gets Stop's bounds on screen for the flood. + */ +@Composable +private fun StopConfirm( + open: Boolean, + pill: Rect?, + controllerActive: Boolean, + onCancel: () -> Unit, + onStop: (Rect?) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val density = LocalDensity.current + val left = remember { Animatable(0f) } + val bottom = remember { Animatable(0f) } + val tint = remember { Animatable(0f) } + val dim = remember { Animatable(0f) } + val items = remember { List(3) { Animatable(0f) } } + // Composed from the first open until it has folded back into the pill. + var shown by remember { mutableStateOf(false) } + var grown by remember { mutableStateOf(false) } + var content by remember { mutableStateOf(null) } + val cancelFocus = remember { FocusRequester() } + var stopFocused by remember { mutableStateOf(false) } + var cancelFocused by remember { mutableStateOf(false) } + var stopCoords by remember { mutableStateOf(null) } + val view = LocalView.current + if (!open && !shown) return + val pull = with(density) { StepPull.toPx() } + + LaunchedEffect(open, pill, content) { + val p = pill ?: return@LaunchedEffect + val c = content ?: return@LaunchedEffect + val boxLeft = (p.right - c.width).coerceAtLeast(0f) + val boxBottom = p.bottom + pull + c.height + if (open) { + shown = true + if (!grown) { + left.snapTo(p.left); bottom.snapTo(p.bottom); tint.snapTo(0f) + items.forEach { it.snapTo(0f) } + grown = true + } + coroutineScope { + launch { dim.animateTo(1f, Motion.tw(260)) } + launch { tint.animateTo(1f, Motion.tw(200)) } + launch { bottom.animateTo(boxBottom, Motion.sp(0.7f, 380f)) } + launch { delay(Motion.ms(110).toLong()); left.animateTo(boxLeft, Motion.sp(0.55f, 300f)) } + items.forEachIndexed { i, a -> + launch { delay(Motion.ms(260 + i * 55).toLong()); a.animateTo(1f, Motion.tw(220)) } + } + if (controllerActive) launch { + delay(Motion.ms(300).toLong()) + withFrameNanos { } + runCatching { cancelFocus.requestFocus() } + } + } + } else if (grown) { + coroutineScope { + items.forEach { launch { it.animateTo(0f, Motion.tw(90)) } } + launch { delay(Motion.ms(60).toLong()); dim.animateTo(0f, Motion.tw(300)) } + launch { delay(Motion.ms(60).toLong()); left.animateTo(p.left, Motion.sp(1f, 420f)) } + launch { delay(Motion.ms(160).toLong()); bottom.animateTo(p.bottom, Motion.sp(1f, 380f)) } + launch { delay(Motion.ms(200).toLong()); tint.animateTo(0f, Motion.tw(200)) } + } + grown = false + shown = false + content = null + } + } + val p = pill ?: return + + Box(Modifier.fillMaxSize()) { + // The rest of the sheet dims behind it, and a tap there cancels. + Box( + Modifier.fillMaxSize().graphicsLayer { alpha = dim.value }.background(Color(0x59000000)) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open, onClick = onCancel), + ) + val path = remember { Path() } + val boxCorner = with(density) { StepBoxCorner.toPx() } + val fillet = with(density) { StepFillet.toPx() } + val rim = with(density) { 1.4.dp.toPx() } + val restFill = colors.surface + val hotFill = lerp(colors.surface, colors.error, 0.22f) + Canvas(Modifier.fillMaxSize()) { + if (!grown) return@Canvas + stepPath(path, p, left.value, bottom.value, p.bottom + pull, boxCorner, fillet) + drawPath(path, lerp(hotFill, restFill, tint.value)) + drawPath(path, colors.error.copy(alpha = lerp(0.9f, 0.55f, tint.value)), style = Stroke(rim)) + } + // The pill's label rides on top, as the pull's handle. + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.offset { IntOffset(p.left.roundToInt(), p.top.roundToInt()) } + .size(with(density) { p.width.toDp() }, with(density) { p.height.toDp() }) + .padding(start = 12.dp, end = 14.dp), + ) { + Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp)) + Text("Stop", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error) + } + val maxW = with(density) { StepBoxMaxWidth.toPx() }.coerceAtMost(p.right) + Column( + modifier = Modifier + .offset { IntOffset((p.right - (content?.width ?: maxW.roundToInt())).roundToInt(), (p.bottom + pull).roundToInt()) } + .width(with(density) { maxW.toDp() }) + .onSizeChanged { if (content != it) content = it } + .controllerBack(onCancel) + .onPreviewKeyEvent { e -> + // Keep a controller in here, as the dialog's own window used to. + if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (e.key) { + Key.DirectionUp, Key.DirectionDown -> true + Key.DirectionLeft -> cancelFocused + Key.DirectionRight -> stopFocused + else -> false + } + } + .padding(start = 18.dp, end = 12.dp, top = 14.dp, bottom = 12.dp), + ) { + Text( + "Stop session?", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, + modifier = Modifier.graphicsLayer { alpha = items[0].value; translationY = (1f - items[0].value) * 6.dp.toPx() }, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + modifier = Modifier.fillMaxWidth().padding(top = 14.dp), + ) { + ConfirmChoice( + "Cancel", danger = false, enabled = open, + modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() } + .focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused }, + onClick = onCancel, + ) + ConfirmChoice( + "Stop", danger = true, enabled = open, + modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() } + .onFocusChanged { stopFocused = it.isFocused } + .onGloballyPositioned { stopCoords = it }, + ) { + val at = IntArray(2).also { view.rootView.getLocationOnScreen(it) } + onStop(stopCoords?.takeIf { it.isAttached }?.boundsInWindow()?.translate(at[0].toFloat(), at[1].toFloat())) + } + } + } + } +} + +/** One of the confirm's two choices: a capsule that fills when focused; Stop in the danger red. */ +@Composable +private fun ConfirmChoice(label: String, danger: Boolean, enabled: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val shape = RoundedCornerShape(20.dp) + val accent = if (danger) colors.error else pal.signal + Box( + contentAlignment = Alignment.Center, + modifier = modifier.heightIn(min = 40.dp).clip(shape) + .background(if (hot) accent.copy(alpha = if (danger) 0.18f else 0.14f) else Color.Transparent) + .glideBorder(hot, shape, accent) + .hoverable(src) + .clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp), + ) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (danger) colors.error else colors.onBackground) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 1b36b1c4b..b332958d4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -148,7 +148,7 @@ internal fun TabStrip( .then(focusRequesters?.getOrNull(i)?.let { Modifier.focusRequester(it) } ?: Modifier) .clip(tabShape) .background(if (on) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) - .border(2.dp, if (!hot) Color.Transparent else if (on) colors.onBackground else pal.signal, tabShape) + .glideBorder(hot, tabShape, if (on) colors.onBackground else pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = pick) .controllerConfirm(onClick = pick) .padding(horizontal = 16.dp, vertical = 12.dp), @@ -297,7 +297,6 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo val src = remember { MutableInteractionSource() } val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value val pal = LocalPalette.current - val edge by animateColorAsState(if (open || hot) pal.signal else pal.line2, Motion.tw(200), label = "chipEdge") val rot by animateFloatAsState(if (open) 180f else 0f, Motion.sp(0.6f), label = "chipCaret") Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, @@ -306,7 +305,7 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo .heightIn(min = 44.dp) .clip(RoundedCornerShape(10.dp)) .background(colors.surfaceVariant) - .border(1.dp, edge, RoundedCornerShape(10.dp)) + .glideBorder(hot, RoundedCornerShape(10.dp), pal.signal, if (open) pal.signal else pal.line2) .alpha(if (enabled) 1f else 0.5f) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) .controllerConfirm(enabled = enabled, onClick = onClick) @@ -400,7 +399,6 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul val src = remember { MutableInteractionSource() } val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value val track by animateColorAsState(if (checked) pal.signal else colors.surfaceVariant, Motion.tw(180), label = "switchTrack") - val edge by animateColorAsState(if (hot) pal.signal else if (checked) pal.signal else pal.line2, Motion.tw(180), label = "switchEdge") val knob by animateFloatAsState(if (checked) 1f else 0f, Motion.sp(0.7f), label = "switchKnob") val flip = { onChange(!checked) } val shape = RoundedCornerShape(99.dp) @@ -417,7 +415,7 @@ fun ToggleSwitch(checked: Boolean, enabled: Boolean = true, label: String? = nul ) { Box( Modifier.size(width = 52.dp, height = 30.dp).clip(shape).background(track) - .border(if (hot) 2.dp else 1.dp, if (hot && checked) colors.onBackground else edge, shape), + .glideBorder(hot, shape, if (checked) colors.onBackground else pal.signal, if (checked) pal.signal else pal.line2), ) { Box( Modifier.padding(4.dp).size(22.dp) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt index da057cb72..5c6002f68 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -411,7 +411,7 @@ private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boo .graphicsLayer { scaleX = scale; scaleY = scale } .clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .padding(horizontal = 12.dp, vertical = 10.dp), ) { @@ -449,7 +449,7 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) { color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant, modifier = Modifier.paneItem("pill:$label").clip(shape) .background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, shape) + .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) .padding(horizontal = 14.dp, vertical = 9.dp), @@ -478,7 +478,7 @@ internal fun InstalledAppsGrid(a: FrontEndActions) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).fillMaxHeight().paneItem("tile:flatpak:${app.id}") .clip(Shape14).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line, Shape14) + .glideBorder(hot, Shape14, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onFlatpakApp(app.id, app.name) } .padding(horizontal = 12.dp, vertical = 10.dp), ) { @@ -536,7 +536,7 @@ internal fun AppImagesSection(a: FrontEndActions, runtimeReady: Boolean) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).paneItem("tile:appimage:${item.id}").clip(Shape14) .background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent) - .border(2.dp, if (hot) pal.signal else Color.Transparent, Shape14) + .glideBorder(hot, Shape14, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { a.onAppImage(item.guestDir, item.name) } .padding(horizontal = 12.dp, vertical = 10.dp), ) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt index 936ce1555..6d824a604 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt @@ -213,7 +213,7 @@ private fun ReleaseNotes(title: String, notes: String, key: String) { Text( if (open) "Less" else "More", fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, modifier = Modifier.padding(top = 2.dp).offset(x = (-6).dp).paneItem("notes:more") - .clip(Shape12).border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12) + .clip(Shape12).glideBorder(hot, Shape12, pal.signal) .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle) .controllerConfirm(onClick = toggle) .padding(horizontal = 6.dp, vertical = 4.dp), @@ -273,13 +273,12 @@ private fun ChannelCard( val src = remember { MutableInteractionSource() } val hot = rememberHot(src) // Focus shows at once, as on the Components page: no ripple, no fade. - val edge = if (hot || selected) pal.signal else pal.line val fill = if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().paneItem("channel:$title") - .clip(Shape14).background(colors.surface).background(fill).border(if (hot) 2.dp else 1.dp, edge, Shape14) + .clip(Shape14).background(colors.surface).background(fill).glideBorder(hot, Shape14, pal.signal, if (selected) pal.signal else pal.line) .alpha(if (enabled) 1f else 0.55f) .hoverable(src) .clickable(interactionSource = src, indication = null, enabled = enabled, role = Role.RadioButton, onClick = onClick) @@ -316,7 +315,7 @@ private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) { .heightIn(min = 52.dp) .clip(Shape12) .background(colors.surface).background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.08f) else Color.Transparent) - .border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape12) + .glideBorder(hot, Shape12, pal.signal, if (selected) pal.signal else pal.line) .hoverable(src) .clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick) .controllerConfirm(onClick = onClick) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt index 52cde55e8..a067ef020 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/WirelessAdbFixPage.kt @@ -381,7 +381,7 @@ internal fun AdbTextField( .fillMaxWidth() .height(inputHeight) .background(colors.surfaceVariant, shape) - .border(1.dp, if (focused) palette.signal else palette.line2, shape) + .glideBorder(focused, shape, palette.signal, palette.line2) .padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart, ) {