feat: grow the stop confirm out of the Stop pill as a stepped box

This commit is contained in:
Kurt Himebauch committed 2026-09-30 16:14:52 -04:00
1 parent 1c196c1e79
commit 76b4ea2c59
1 file changed
+256 -43
@@ -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<HomeApp.LaunchableApp?>(null) }
var confirmStop by remember { mutableStateOf(false) }
var sheetCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(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<androidx.compose.ui.geometry.Rect?>(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
@@ -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<IntSize?>(null) }
val cancelFocus = remember { FocusRequester() }
var stopFocused by remember { mutableStateOf(false) }
var cancelFocused by remember { mutableStateOf(false) }
var stopCoords by remember { mutableStateOf<LayoutCoordinates?>(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)
}
}