| 1 |
package io.oxygenhub.android.ui.backdrop |
| 2 |
|
| 3 |
import android.content.Context |
| 4 |
import android.os.PowerManager |
| 5 |
import android.provider.Settings |
| 6 |
import androidx.compose.animation.core.animateFloatAsState |
| 7 |
import androidx.compose.animation.core.tween |
| 8 |
import androidx.compose.foundation.Canvas |
| 9 |
import androidx.compose.foundation.layout.Box |
| 10 |
import androidx.compose.foundation.layout.BoxScope |
| 11 |
import androidx.compose.foundation.layout.fillMaxSize |
| 12 |
import androidx.compose.foundation.layout.offset |
| 13 |
import androidx.compose.foundation.layout.padding |
| 14 |
import androidx.compose.material3.MaterialTheme |
| 15 |
import androidx.compose.material3.Surface |
| 16 |
import androidx.compose.material3.Text |
| 17 |
import androidx.compose.runtime.Composable |
| 18 |
import androidx.compose.runtime.DisposableEffect |
| 19 |
import androidx.compose.runtime.LaunchedEffect |
| 20 |
import androidx.compose.runtime.getValue |
| 21 |
import androidx.compose.runtime.mutableIntStateOf |
| 22 |
import androidx.compose.runtime.mutableLongStateOf |
| 23 |
import androidx.compose.runtime.mutableStateOf |
| 24 |
import androidx.compose.runtime.remember |
| 25 |
import androidx.compose.runtime.setValue |
| 26 |
import androidx.compose.ui.Modifier |
| 27 |
import androidx.compose.ui.draw.alpha |
| 28 |
import androidx.compose.ui.draw.clipToBounds |
| 29 |
import androidx.compose.ui.geometry.Offset |
| 30 |
import androidx.compose.ui.graphics.Brush |
| 31 |
import androidx.compose.ui.graphics.Color |
| 32 |
import androidx.compose.ui.graphics.Path |
| 33 |
import androidx.compose.ui.graphics.drawscope.DrawScope |
| 34 |
import androidx.compose.ui.graphics.drawscope.Stroke |
| 35 |
import androidx.compose.ui.graphics.luminance |
| 36 |
import androidx.compose.ui.input.pointer.PointerEventPass |
| 37 |
import androidx.compose.ui.input.pointer.pointerInput |
| 38 |
import androidx.compose.ui.layout.onSizeChanged |
| 39 |
import androidx.compose.ui.platform.LocalContext |
| 40 |
import androidx.compose.ui.platform.LocalDensity |
| 41 |
import androidx.compose.ui.platform.LocalInspectionMode |
| 42 |
import androidx.compose.ui.unit.IntOffset |
| 43 |
import androidx.compose.ui.unit.IntSize |
| 44 |
import androidx.compose.ui.unit.dp |
| 45 |
import io.oxygenhub.android.ui.theme.LocalStatusColors |
| 46 |
import java.nio.ByteBuffer |
| 47 |
import java.nio.ByteOrder |
| 48 |
import kotlin.math.roundToInt |
| 49 |
import kotlinx.coroutines.isActive |
| 50 |
import uniffi.rust_core.Backdrop as Scene |
| 51 |
import uniffi.rust_core.BackdropLabel |
| 52 |
|
| 53 |
/** |
| 54 |
* The website's living backgrounds (#23, the hub's docs/backgrounds.md): |
| 55 |
* one of the hub's own scenes, stepped in rust-core and drawn here as its |
| 56 |
* WebGL draws them in a browser, behind [content]. A finger is its cursor: |
| 57 |
* the touches pass through to the content and the scene sees them too. |
| 58 |
* |
| 59 |
* As on the web, drawing halves its rate 3 s after the last touch and stops |
| 60 |
* after 2 minutes, until the next; it holds still when the system's |
| 61 |
* animations are off or battery saver is on. |
| 62 |
*/ |
| 63 |
@Composable |
| 64 |
fun Backdrop( |
| 65 |
key: Any?, |
| 66 |
make: (still: Boolean) -> Scene, |
| 67 |
modifier: Modifier = Modifier, |
| 68 |
content: @Composable BoxScope.() -> Unit, |
| 69 |
) { |
| 70 |
// Previews and screenshot tests have no Rust core to step a scene with. |
| 71 |
if (LocalInspectionMode.current) { |
| 72 |
Box(modifier) { content() } |
| 73 |
return |
| 74 |
} |
| 75 |
val context = LocalContext.current |
| 76 |
val still = remember(context) { backdropStill(context) } |
| 77 |
val scene = remember(key, still) { make(still) } |
| 78 |
DisposableEffect(scene) { onDispose { scene.close() } } |
| 79 |
val colours = backdropColours() |
| 80 |
val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f |
| 81 |
val density = LocalDensity.current.density |
| 82 |
var size by remember { mutableStateOf(IntSize.Zero) } |
| 83 |
var frame by remember(scene) { mutableStateOf(Frame.EMPTY) } |
| 84 |
var label by remember(scene) { mutableStateOf<BackdropLabel?>(null) } |
| 85 |
var touchedAt by remember(scene) { mutableLongStateOf(System.nanoTime()) } |
| 86 |
var running by remember(scene) { mutableStateOf(false) } |
| 87 |
var wake by remember(scene) { mutableIntStateOf(0) } |
| 88 |
|
| 89 |
LaunchedEffect(scene, colours) { scene.colours(colours) } |
| 90 |
LaunchedEffect(scene, size, colours, wake) { |
| 91 |
if (size == IntSize.Zero) return@LaunchedEffect |
| 92 |
scene.resize(size.width / density, size.height / density) |
| 93 |
running = true |
| 94 |
var last = -1L |
| 95 |
var n = 0 |
| 96 |
while (isActive) { |
| 97 |
val more = androidx.compose.runtime.withFrameNanos { t -> |
| 98 |
val idle = (System.nanoTime() - touchedAt) / 1e9 |
| 99 |
n++ |
| 100 |
if (!still && idle > HALF_RATE_AFTER_S && n % 2 == 1) return@withFrameNanos true |
| 101 |
val dt = if (last < 0) 0f else ((t - last) / 1e9).toFloat() |
| 102 |
last = t |
| 103 |
val busy = scene.step(dt) |
| 104 |
frame = Frame.of(scene.frame()) |
| 105 |
label = scene.label() |
| 106 |
if (still) busy else idle < STOP_AFTER_S || busy |
| 107 |
} |
| 108 |
if (!more) break |
| 109 |
} |
| 110 |
running = false |
| 111 |
} |
| 112 |
|
| 113 |
Box( |
| 114 |
modifier |
| 115 |
// A mark near the edge stays in its band (#24), never on the content around it. |
| 116 |
.clipToBounds() |
| 117 |
.onSizeChanged { size = it } |
| 118 |
.pointerInput(scene) { |
| 119 |
awaitPointerEventScope { |
| 120 |
while (true) { |
| 121 |
// Seen on the way down, never consumed: the content scrolls and taps as before. |
| 122 |
val e = awaitPointerEvent(PointerEventPass.Initial) |
| 123 |
val p = e.changes.firstOrNull() ?: continue |
| 124 |
scene.point(p.position.x / density, p.position.y / density, p.pressed) |
| 125 |
touchedAt = System.nanoTime() |
| 126 |
if (!running) wake++ |
| 127 |
} |
| 128 |
} |
| 129 |
}, |
| 130 |
) { |
| 131 |
Canvas(Modifier.fillMaxSize()) { draw(frame, density, dark) } |
| 132 |
Label(label, density) |
| 133 |
content() |
| 134 |
} |
| 135 |
} |
| 136 |
|
| 137 |
/** The name a resting finger shows, centred above the cluster, fading in and out (the web's `.backdrop-label`). */ |
| 138 |
@Composable |
| 139 |
private fun Label(label: BackdropLabel?, density: Float) { |
| 140 |
// The last name stays while it fades out. |
| 141 |
val held = remember { arrayOfNulls<BackdropLabel>(1) } |
| 142 |
if (label != null) held[0] = label |
| 143 |
val alpha by animateFloatAsState(if (label != null) 1f else 0f, tween(220), label = "label") |
| 144 |
val l = held[0] ?: return |
| 145 |
var box by remember { mutableStateOf(IntSize.Zero) } |
| 146 |
Surface( |
| 147 |
shape = MaterialTheme.shapes.small, |
| 148 |
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), |
| 149 |
tonalElevation = 2.dp, |
| 150 |
modifier = Modifier |
| 151 |
.offset { IntOffset((l.x * density - box.width / 2f).roundToInt(), (l.y * density - box.height - 8 * density).roundToInt()) } |
| 152 |
.onSizeChanged { box = it } |
| 153 |
.alpha(alpha), |
| 154 |
) { |
| 155 |
Text(l.text, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) |
| 156 |
} |
| 157 |
} |
| 158 |
|
| 159 |
/** A frame's floats: marks 8 each (x, y, size, shape, premultiplied rgba), links 12 each (two ends of x, y, rgba). */ |
| 160 |
internal class Frame(val marks: FloatArray, val links: FloatArray) { |
| 161 |
companion object { |
| 162 |
val EMPTY = Frame(FloatArray(0), FloatArray(0)) |
| 163 |
|
| 164 |
fun of(f: uniffi.rust_core.BackdropFrame) = Frame(floats(f.marks), floats(f.links)) |
| 165 |
|
| 166 |
fun floats(b: ByteArray): FloatArray { |
| 167 |
val out = FloatArray(b.size / 4) |
| 168 |
ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().get(out) |
| 169 |
return out |
| 170 |
} |
| 171 |
} |
| 172 |
} |
| 173 |
|
| 174 |
/** A premultiplied colour back to a straight one. */ |
| 175 |
internal fun straight(r: Float, g: Float, b: Float, a: Float): Color = |
| 176 |
if (a <= 0f) Color.Transparent else Color((r / a).coerceIn(0f, 1f), (g / a).coerceIn(0f, 1f), (b / a).coerceIn(0f, 1f), a.coerceIn(0f, 1f)) |
| 177 |
|
| 178 |
private val path = Path() |
| 179 |
|
| 180 |
private fun DrawScope.draw(f: Frame, density: Float, dark: Boolean) { |
| 181 |
val l = f.links |
| 182 |
var i = 0 |
| 183 |
while (i + 12 <= l.size) { |
| 184 |
val a = Offset(l[i] * density, l[i + 1] * density) |
| 185 |
val b = Offset(l[i + 6] * density, l[i + 7] * density) |
| 186 |
val ca = straight(l[i + 2], l[i + 3], l[i + 4], l[i + 5]) |
| 187 |
val cb = straight(l[i + 8], l[i + 9], l[i + 10], l[i + 11]) |
| 188 |
drawLine(Brush.linearGradient(listOf(ca, cb), a, b), a, b, strokeWidth = density) |
| 189 |
i += 12 |
| 190 |
} |
| 191 |
val m = f.marks |
| 192 |
i = 0 |
| 193 |
while (i + 8 <= m.size) { |
| 194 |
val c = Offset(m[i] * density, m[i + 1] * density) |
| 195 |
val s = m[i + 2] * density |
| 196 |
val colour = straight(m[i + 4], m[i + 5], m[i + 6], m[i + 7]) |
| 197 |
// On a dark page a lit mark glows a little, as the web's do on a bright screen. |
| 198 |
if (dark && colour.alpha > 0.5f) drawCircle(colour.copy(alpha = colour.alpha * 0.14f), s * 2.6f, c) |
| 199 |
mark(c, s, m[i + 3].roundToInt(), colour) |
| 200 |
i += 8 |
| 201 |
} |
| 202 |
} |
| 203 |
|
| 204 |
/** One mark, as the hub's fragment shader draws it (crates/backdrop/src/gl.rs). */ |
| 205 |
private fun DrawScope.mark(c: Offset, s: Float, shape: Int, colour: Color) { |
| 206 |
when (shape) { |
| 207 |
0 -> drawCircle(colour, s, c) // a push: a dot |
| 208 |
1 -> polygon(c, colour, floatArrayOf(1f, 0f, 0f, 1f, -1f, 0f, 0f, -1f), s / 0.85f) // a proposal: a diamond |
| 209 |
2 -> drawCircle(colour, s * 0.72f, c, style = Stroke(s * 0.56f)) // an issue: a ring |
| 210 |
3 -> polygon(c, colour, STAR, s) // a release: a star |
| 211 |
4 -> drawCircle(colour, s * 0.85f, c, style = Stroke(s * 0.24f)) // a repository: a thin ring |
| 212 |
else -> { // a Flow run: a square |
| 213 |
val h = s / 1.15f |
| 214 |
drawRect(colour, Offset(c.x - h, c.y - h), androidx.compose.ui.geometry.Size(2 * h, 2 * h)) |
| 215 |
} |
| 216 |
} |
| 217 |
} |
| 218 |
|
| 219 |
/** The shader's four-pointed star: two thin diamonds crossed, meeting at 1/4.5. */ |
| 220 |
private val STAR = (1f / 4.5f).let { k -> floatArrayOf(1f, 0f, k, k, 0f, 1f, -k, k, -1f, 0f, -k, -k, 0f, -1f, k, -k) } |
| 221 |
|
| 222 |
private fun DrawScope.polygon(c: Offset, colour: Color, unit: FloatArray, r: Float) { |
| 223 |
path.reset() |
| 224 |
path.moveTo(c.x + unit[0] * r, c.y + unit[1] * r) |
| 225 |
var j = 2 |
| 226 |
while (j < unit.size) { |
| 227 |
path.lineTo(c.x + unit[j] * r, c.y + unit[j + 1] * r) |
| 228 |
j += 2 |
| 229 |
} |
| 230 |
path.close() |
| 231 |
drawPath(path, colour) |
| 232 |
} |
| 233 |
|
| 234 |
/** |
| 235 |
* The scene's colours from the theme, as the hub's CSS gives the web's: |
| 236 |
* `--bd-mark`, `--bd-lit` and `--bd-link`, then ok, warn and bad. |
| 237 |
*/ |
| 238 |
@Composable |
| 239 |
private fun backdropColours(): List<Float> { |
| 240 |
val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f |
| 241 |
val s = LocalStatusColors.current |
| 242 |
val (mark, lit, link) = if (dark) { |
| 243 |
Triple(Color(0, 224, 240).copy(alpha = 0.12f), Color(0, 240, 255).copy(alpha = 0.85f), Color(0, 240, 255).copy(alpha = 0.35f)) |
| 244 |
} else { |
| 245 |
Triple(Color(95, 106, 129).copy(alpha = 0.18f), Color(0, 100, 224).copy(alpha = 0.7f), Color(0, 100, 224).copy(alpha = 0.25f)) |
| 246 |
} |
| 247 |
return remember(dark, s) { listOf(mark, lit, link, s.ok, s.warn, s.bad).flatMap { listOf(it.red, it.green, it.blue, it.alpha) } } |
| 248 |
} |
| 249 |
|
| 250 |
/** Still, as the web is under prefers-reduced-motion: the system's animations off, or battery saver on. */ |
| 251 |
private fun backdropStill(context: Context): Boolean { |
| 252 |
val scale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) |
| 253 |
val saver = context.getSystemService(PowerManager::class.java)?.isPowerSaveMode == true |
| 254 |
return scale == 0f || saver |
| 255 |
} |
| 256 |
|
| 257 |
/** As the hub's engine (crates/backdrop/src/lib.rs): a still finger halves the rate after 3 s; 2 minutes stops it. */ |
| 258 |
private const val HALF_RATE_AFTER_S = 3.0 |
| 259 |
private const val STOP_AFTER_S = 120.0 |