From 110f5e595d480a3ff8ea30d380cea46845249048 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sat, 26 Sep 2026 22:44:09 -0400 Subject: [PATCH] feat(session): animate the logo on the loading screen --- .../com/droiddeck/launcher/SessionActivity.kt | 8 + .../droiddeck/launcher/ui/SessionOverlay.kt | 196 ++++++++---------- .../com/droiddeck/launcher/ui/SteamHome.kt | 10 +- .../com/droiddeck/launcher/ui/Throbber.kt | 165 +++++++++++++++ 4 files changed, 270 insertions(+), 109 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/Throbber.kt diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 405fcfd9a..0b24f188d 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -944,6 +944,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus() drawerControllerActive = true } + // While the session starts, B is the loading screen's Cancel, as its corner says. + if (fromController && event.keyCode == KeyEvent.KEYCODE_BUTTON_B && ::loading.isInitialized && loading.visible && !loading.ended && !drawerOpen) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + SessionService.stop(this) + finish() + } + return true + } if (drawerOpen) { if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 3b0bf3659..7082fc5b4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -1,5 +1,18 @@ package com.droiddeck.launcher.ui +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.runtime.produceState +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import com.droiddeck.launcher.frontend.Library +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.offset @@ -191,7 +204,6 @@ fun LoadingOverlay( title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null, ) { val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current val stages = remember(steam) { loadStages(steam) } var reached by remember { mutableStateOf(0) } LaunchedEffect(step, steam) { @@ -199,128 +211,100 @@ fun LoadingOverlay( if (at > reached) reached = at } val active = reached.coerceAtMost(stages.lastIndex) - val stageRows: @Composable () -> Unit = { - stages.forEachIndexed { i, stage -> - StageRow( - label = stage.label, - state = when { i < active -> StageMark.DONE; i == active -> StageMark.ACTIVE; else -> StageMark.PENDING }, - detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stage.detail, - percent = percent, - ) - } + val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stages[active].detail + val context = LocalContext.current + // The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in. + val games by produceState(emptyList()) { + value = withContext(Dispatchers.IO) { runCatching { Library.steamGames(context).sortedByDescending { it.lastPlayed } }.getOrDefault(emptyList()) } } - androidx.compose.foundation.layout.BoxWithConstraints( + Box( modifier = Modifier .fillMaxSize() .background(colors.background) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}, ) { - // A 4:3, square or short screen has no room for the side-by-side layout: one column, the spinner - // beside the title, and the tip and Cancel along the bottom where they stay in view. - if (maxWidth < 600.dp || maxHeight < 400.dp) Column(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 20.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(48.dp)) { - if (!ended) CircularProgressIndicator(color = pal.signal, trackColor = pal.line, strokeWidth = 2.dp, modifier = Modifier.fillMaxSize()) - Image(painterResource(R.drawable.logo), contentDescription = null, modifier = Modifier.size(32.dp)) - } - Text( - if (ended) "The session ended" else title, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, - maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - ) - if (!ended && elapsed.isNotEmpty()) Text(elapsed.substringBefore(' '), fontSize = 14.sp, color = colors.onSurfaceVariant) - } - Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 12.dp)) { - if (ended) Text(step, color = colors.onSurfaceVariant, fontSize = 14.sp) - else stageRows() - } - if (!ended && (hint.isNotEmpty() || onCancel != null)) { - Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.fillMaxWidth().padding(top = 10.dp), - ) { - Text(hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - if (onCancel != null) SecondaryButton("Cancel", compact = true, onClick = onCancel) - } - } - } else Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.weight(0.4f).fillMaxHeight()) { - if (!ended) CircularProgressIndicator( - color = pal.signal, trackColor = pal.line, strokeWidth = 2.dp, modifier = Modifier.size(150.dp), - ) - Image(painterResource(R.drawable.logo), contentDescription = null, modifier = Modifier.size(84.dp)) - } - // Centred when it fits, scrolling when a long failure message does not. - Box(contentAlignment = Alignment.CenterStart, modifier = Modifier.weight(0.6f).fillMaxHeight()) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) - .padding(start = 8.dp, end = 48.dp, top = 28.dp, bottom = 28.dp), - ) { - Text( - if (ended) "The session ended" else title, - color = colors.onBackground, fontSize = 28.sp, fontWeight = FontWeight.Bold, + CapsuleWall(games, driftMs = 65_000, modifier = Modifier.alpha(0.3f)) + Box( + Modifier.fillMaxSize().drawBehind { + drawRect( + Brush.radialGradient( + 0f to colors.background.copy(alpha = 0.94f), + 0.6f to colors.background.copy(alpha = 0.72f), + 1f to colors.background.copy(alpha = 0.55f), + center = Offset(size.width / 2f, size.height * 0.46f), radius = maxOf(size.width, size.height) * 0.55f, + ), ) - if (ended) { - Text(step, color = colors.onSurfaceVariant, fontSize = 14.sp, modifier = Modifier.padding(top = 10.dp)) - } else { - if (elapsed.isNotEmpty()) Text(elapsed, color = colors.onSurfaceVariant, fontSize = 13.sp, modifier = Modifier.padding(top = 2.dp)) - Spacer(Modifier.height(16.dp)) - stageRows() - if (hint.isNotEmpty()) Text( - hint, color = colors.onSurfaceVariant, fontSize = 14.sp, - modifier = Modifier.padding(top = 16.dp).widthIn(max = 480.dp) - .clip(RoundedCornerShape(12.dp)).border(1.dp, pal.line2, RoundedCornerShape(12.dp)) - .padding(horizontal = 14.dp, vertical = 10.dp), - ) - if (onCancel != null) Box(Modifier.padding(top = 16.dp)) { SecondaryButton("Cancel", onClick = onCancel) } - } - } + }, + ) + Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.7f to Color.Transparent, 1f to colors.background.copy(alpha = 0.95f)))) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.align(Alignment.Center).verticalScroll(rememberScrollState()).padding(horizontal = 32.dp, vertical = 24.dp), + ) { + LogoThrobber(Modifier.width(if (ended) 88.dp else 110.dp), running = !ended) + Spacer(Modifier.height(30.dp)) + Text( + if (ended) "The session ended" else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, + color = colors.onBackground, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + Text( + if (ended) step else detail, fontSize = 14.sp, lineHeight = 20.sp, color = colors.onSurfaceVariant, textAlign = TextAlign.Center, + maxLines = if (ended) 12 else 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp).widthIn(max = 520.dp), + ) + if (!ended) LoadSegments(stages.size, active, percent, Modifier.padding(top = 18.dp)) } - } + if (!ended && hint.isNotEmpty()) Text( + hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center, + maxLines = 3, overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.BottomCenter).padding(start = 120.dp, end = 120.dp, bottom = 28.dp), + ) + if (!ended && onCancel != null) CancelHint(onCancel, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp)) } } -private enum class StageMark { DONE, ACTIVE, PENDING } - +/** One segment per stage: done ones filled, the current one filling (or running, with no percent). */ @Composable -private fun StageRow(label: String, state: StageMark, detail: String, percent: Int) { - val colors = MaterialTheme.colorScheme +private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) { val pal = LocalPalette.current - val shape = RoundedCornerShape(14.dp) - Column( - modifier = Modifier.widthIn(max = 520.dp).fillMaxWidth() - .then( - if (state == StageMark.ACTIVE) Modifier.padding(vertical = 4.dp).clip(shape).background(colors.surface) - .border(1.dp, pal.line, shape).padding(horizontal = 12.dp, vertical = 10.dp) - else Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - ), + val track = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.12f) + val run = rememberInfiniteTransition(label = "segment") + .animateFloat(-0.4f, 1f, infiniteRepeatable(tween(1300, easing = FastOutSlowInEasing)), label = "run") + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" }, ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - when (state) { - StageMark.DONE -> Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp).clip(CircleShape).background(pal.good.copy(alpha = 0.16f))) { - Icon(Icons.Filled.Check, contentDescription = "Done", tint = pal.good, modifier = Modifier.size(15.dp)) - } - StageMark.ACTIVE -> Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp).border(2.dp, pal.signal, CircleShape)) { - Box(Modifier.size(9.dp).clip(CircleShape).background(pal.signal)) - } - StageMark.PENDING -> Box(Modifier.size(24.dp).border(2.dp, pal.line2, CircleShape)) - } - Text( - label, fontSize = 16.sp, modifier = Modifier.weight(1f), - fontWeight = if (state == StageMark.ACTIVE) FontWeight.SemiBold else FontWeight.Normal, - color = if (state == StageMark.PENDING) colors.onSurfaceVariant else colors.onBackground, + repeat(count) { i -> + Box( + Modifier.width(44.dp).height(3.dp).clip(RoundedCornerShape(2.dp)) + .background(if (i < active) pal.signal else track) + .drawBehind { + if (i != active) return@drawBehind + if (percent >= 0) drawRect(pal.signal, size = Size(size.width * percent.coerceIn(0, 100) / 100f, size.height)) + else drawRect(pal.signal, topLeft = Offset(size.width * run.value, 0f), size = Size(size.width * 0.4f, size.height)) + }, ) - if (state == StageMark.ACTIVE && percent >= 0) Text("$percent%", fontSize = 14.sp, color = colors.onSurfaceVariant) } - if (state == StageMark.ACTIVE) { - Text( - detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 36.dp, top = 6.dp), - ) - val bar = Modifier.padding(start = 36.dp, top = 8.dp).fillMaxWidth().height(4.dp) - if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = bar, color = pal.signal, trackColor = pal.line) - else LinearProgressIndicator(modifier = bar, color = pal.signal, trackColor = pal.line) + } +} + +/** Cancel as the pad's B, in the corner; a tap works too. */ +@Composable +private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val src = remember { MutableInteractionSource() } + val hot = src.collectIsHoveredAsState().value || src.collectIsFocusedAsState().value + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(22.dp)) + .background(if (hot) colors.onBackground.copy(alpha = 0.08f) else Color.Transparent) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onCancel) + .padding(horizontal = 12.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) { + Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background) } + Text("Cancel", fontSize = 13.sp, color = colors.onSurfaceVariant) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index a1d15f49c..d9daa4b42 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -116,19 +116,23 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) * library draws the same wall in blank capsules, slower. */ @Composable -private fun CapsuleWall(games: List) { +internal fun CapsuleWall( + games: List, + driftMs: Int = if (games.isEmpty()) 75_000 else 40_000, + modifier: Modifier = Modifier, +) { val capW = 112.dp val capH = 168.dp val gap = 14.dp val transition = rememberInfiniteTransition(label = "wall") val drift = transition.animateFloat( 0f, 1f, - infiniteRepeatable(tween(if (games.isEmpty()) 75_000 else 40_000, easing = LinearEasing)), + infiniteRepeatable(tween(driftMs, easing = LinearEasing)), label = "drift", ) // Animations off in the system settings: a still wall. val still = Motion.scale == 0f - BoxWithConstraints(Modifier.fillMaxSize()) { + BoxWithConstraints(modifier.fillMaxSize()) { val wallW = maxWidth * 1.3f val wallH = maxHeight * 2.4f val columns = ((wallW + gap) / (capW + gap)).toInt() + 1 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Throbber.kt b/app/src/main/java/com/droiddeck/launcher/ui/Throbber.kt new file mode 100644 index 000000000..6940e07a8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/Throbber.kt @@ -0,0 +1,165 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.rotate +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * The DroidDeck mark as the loading indicator. Every loop the D's square half bends into an arc, + * the ring they make turns once around the ball, and the half straightens back into the D. + */ +@Composable +internal fun LogoThrobber(modifier: Modifier = Modifier, running: Boolean = true) { + val ink = MaterialTheme.colorScheme.onBackground + val ball = LocalPalette.current.signal + val loop = rememberInfiniteTransition(label = "throbber") + .animateFloat(0f, 1f, infiniteRepeatable(tween(LOOP_MS, easing = LinearEasing)), label = "loop") + // Animations off in the system settings, or nothing running: the plain D. + val still = !running || Motion.scale == 0f + val left = remember { Path() } + val right = remember { Path() } + Canvas(modifier.aspectRatio(LogoShape.WIDTH / LogoShape.HEIGHT)) { + val p = if (still) 0f else loop.value + val bend = when { + p < 0.10f -> 0f + p < 0.24f -> Settle.transform((p - 0.10f) / 0.14f) + p < 0.76f -> 1f + p < 0.90f -> 1f - Settle.transform((p - 0.76f) / 0.14f) + else -> 0f + } + val turn = if (p < 0.16f || p > 0.84f) 0f else Turn.transform((p - 0.16f) / 0.68f) * 360f + val ballScale = when { + p < 0.12f -> 1f + p < 0.30f -> 1f - 0.2f * Settle.transform((p - 0.12f) / 0.18f) + p < 0.70f -> 0.8f + p < 0.88f -> 0.8f + 0.2f * Settle.transform((p - 0.70f) / 0.18f) + else -> 1f + } + val s = size.width / LogoShape.WIDTH + fun px(x: Float, y: Float) = Offset((x - LogoShape.LEFT) * s, y * s) + val centre = px(LogoShape.CX, LogoShape.CY) + LogoShape.left(left, bend, ::px) + LogoShape.right(right, ::px) + rotate(turn, centre) { + drawPath(left, ink) + drawPath(right, ink) + } + drawCircle(ball, radius = LogoShape.BALL * s * ballScale, center = centre) + } +} + +private const val LOOP_MS = 1900 +private val Settle = CubicBezierEasing(0.5f, 0f, 0.3f, 1f) +private val Turn = CubicBezierEasing(0.65f, 0f, 0.35f, 1f) + +/** + * The logo's geometry in its SVG's own units (viewBox -55.98 0 239.92 223.72): a D of a square + * left side and a round right side, cut by a ring around the ball and split top and bottom. + * The left half is sampled along rays from the centre so it can bend, point for point, into the + * mirror of the right half's arc. + */ +private object LogoShape { + const val LEFT = -55.98f + const val WIDTH = 239.92f + const val HEIGHT = 223.72f + const val CX = 63.98f + const val CY = 111.86f + const val BALL = 55.98f + private const val OUTER = 111.86f + private const val INNER = 71.81f + private const val SQUARE_X = -31.98f + private const val SPLIT_LEFT = 56.055f + private const val SPLIT_RIGHT = 71.885f + + private class Pt(val x: Float, val y: Float) + + private fun at(a: Double, r: Float) = Pt((CX + r * cos(a)).toFloat(), (CY + r * sin(a)).toFloat()) + private fun angle(x: Float, y: Float) = atan2((y - CY).toDouble(), (x - CX).toDouble()) + // Angles taken the long way round, through the left: from about -94 degrees down to -266. + private fun leftward(a: Double) = if (a < 0) a else a - 2 * Math.PI + + private val rays: List = run { + val top = angle(SPLIT_LEFT, 0f) + val topCorner = angle(SQUARE_X, 0f) + val bottomCorner = leftward(angle(SQUARE_X, HEIGHT)) + val bottom = leftward(angle(SPLIT_LEFT, HEIGHT)) + // The corners are rays of their own, so the square half keeps sharp corners. + fun span(a: Double, b: Double, n: Int) = List(n) { i -> a + (b - a) * i / (n - 1) } + span(top, topCorner, 9).dropLast(1) + span(topCorner, bottomCorner, 25).dropLast(1) + span(bottomCorner, bottom, 9) + } + + private val square: List = rays.map { a -> + val c = cos(a) + val sn = sin(a) + val hits = buildList { + if (c < -1e-9) add((SQUARE_X - CX) / c) + if (sn < -1e-9) add((0f - CY) / sn) + if (sn > 1e-9) add((HEIGHT - CY) / sn) + } + val t = hits.minOrNull() ?: 0.0 + Pt((CX + t * c).toFloat(), (CY + t * sn).toFloat()) + }.toMutableList().also { it[0] = Pt(SPLIT_LEFT, 0f); it[it.lastIndex] = Pt(SPLIT_LEFT, HEIGHT) } + + private val arc: List = rays.map { at(it, OUTER) }.toMutableList().also { + val dy = sqrt(OUTER * OUTER - (SPLIT_LEFT - CX) * (SPLIT_LEFT - CX)) + it[0] = Pt(SPLIT_LEFT, CY - dy) + it[it.lastIndex] = Pt(SPLIT_LEFT, CY + dy) + } + + private val leftInner: List = run { + val dx = (SPLIT_LEFT - CX).toDouble() + val dy = sqrt(INNER * INNER - dx * dx) + val from = atan2(dy, dx) + val to = atan2(-dy, dx) + 2 * Math.PI + List(rays.size) { i -> at(from + (to - from) * i / (rays.size - 1), INNER) } + } + + private val rightHalf: List = run { + val dx = (SPLIT_RIGHT - CX).toDouble() + val oy = sqrt(OUTER * OUTER - dx * dx) + val iy = sqrt(INNER * INNER - dx * dx) + val outerFrom = atan2(-oy, dx) + val outerTo = atan2(oy, dx) + val innerFrom = atan2(iy, dx) + val innerTo = atan2(-iy, dx) + List(40) { i -> at(outerFrom + (outerTo - outerFrom) * i / 39, OUTER) } + + List(40) { i -> at(innerFrom + (innerTo - innerFrom) * i / 39, INNER) } + } + + /** The left half, [bend] of the way from the square D side (0) to the arc (1). */ + fun left(path: Path, bend: Float, px: (Float, Float) -> Offset) { + path.reset() + square.forEachIndexed { i, q -> + val r = arc[i] + val o = px(q.x + (r.x - q.x) * bend, q.y + (r.y - q.y) * bend) + if (i == 0) path.moveTo(o.x, o.y) else path.lineTo(o.x, o.y) + } + for (q in leftInner) px(q.x, q.y).let { path.lineTo(it.x, it.y) } + path.close() + } + + fun right(path: Path, px: (Float, Float) -> Offset) { + path.reset() + rightHalf.forEachIndexed { i, q -> + val o = px(q.x, q.y) + if (i == 0) path.moveTo(o.x, o.y) else path.lineTo(o.x, o.y) + } + path.close() + } +}