mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76b4ea2c59 | ||
|
|
1c196c1e79 |
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,
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user