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