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