OXYGENHUB
# 1b55d7f47d9f oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / backdrop / Backdrop.kt
11.1 KiB · last changed The widget: the OxygenHub mark and Your sky behind it (closes #30) 4 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.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
internal 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