Compare commits

...
17 changed files with 551 additions and 88 deletions

No files matched your search

@@ -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() }
@@ -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() {
@@ -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)) }
@@ -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),
)
@@ -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<Shape>(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<GlideSource>()
/** 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<FocusGlide?> { 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<Float> = Motion.sp(0.62f, 700f)
/** Trailing edge: slower, settles without a bounce to speak of. */
private fun trail(): AnimationSpec<Float> = Motion.sp(0.78f, 360f)
/** Following a control that moves under the ring (a scroll): tight, no wobble. */
private fun follow(): AnimationSpec<Float> = 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<Job>(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<AnimationSpec<Float>> = List(4) { follow() }
var delays = LongArray(4)
fun go(i: Int, v: Float, spec: AnimationSpec<Float>, 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() }
}
}
@@ -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),
) {
@@ -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),
) {
@@ -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),
@@ -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" }
@@ -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),
@@ -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)) }
}
@@ -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) }
@@ -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
@@ -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<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)
}
}
@@ -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)
@@ -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),
) {
@@ -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)
@@ -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,
) {