OXYGENHUB

The hub's living backgrounds in the app: the mark at sign-in, Your sky on the dashboard, live runs pulse (refs #23) 1dfa631436b6

The hub's backdrop scenes (crates/backdrop, copied by scripts/sync-backdrop.sh) are stepped in rust-core behind a uniffi Backdrop object and drawn by a Compose canvas, as the hub's WebGL draws them: the same shapes, colours from the theme as the hub's CSS gives them, touches seen without being consumed. Your sky reads GET /user/sky (a hub without it shows the quiet field). Drawing halves its rate after 3 s and stops after 2 minutes without a touch, and holds still with animations off or battery saver on. uniffi's Android mode frees objects without java.lang.ref.Cleaner, which API 31 lacks.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e3289a9dcc8f
When
· 2026-10-27 23:45 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
1dfa631436b6be95ee83f84e7aa6e078da3b15d9d96f8df7571a174ef2a8af9c
Depends on
144cbf7bf2b5 1c42a70fcba3 2a15621eca66 4aee5293461d 57c0824300db 5ae4af48ddd2 62429abe5869 98dbd1220929 ca672a3ada1f d1f635be9442 db00632a61d6 e0e4a1c48a77 e2df27a81e5d f821ee741b9f ff65ea456ff1
Depended on by
29a375612f92 263c6cad5e51 aa627197ec18 6823c243eaac 821035db5a1c 840c75813f86 6e2af63ae616 dce8ebd2482a
In bookmarks
main
In tags
v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
Edits
55 atoms, 15 tree operations, 24 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/ui/backdrop/Backdrop.kt +250 −0
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
added app/src/main/java/io/oxygenhub/android/ui/backdrop/Scenes.kt +15 −0
1 +
package io.oxygenhub.android.ui.backdrop
2 +
3 +
import io.oxygenhub.android.api.UserSky
4 +
import uniffi.rust_core.SkyEvent
5 +
import uniffi.rust_core.Backdrop as Scene
6 +
7 +
/** Your sky's events for the scene: each aged by the hub's clock, in days; malformed ones left out. */
8 +
fun skyEvents(sky: UserSky): List<SkyEvent> = sky.events.mapNotNull { e ->
9 +
    if (e.size < 4 || e[0] !in sky.repos.indices || e[1] !in 0L..4L) return@mapNotNull null
10 +
    SkyEvent(e[0].toUInt(), e[1].toUInt(), ((sky.now - e[2]).coerceAtLeast(0) / 86_400_000.0).toFloat(), e[3].coerceIn(1, 10_000).toUInt())
11 +
}
12 +
13 +
/** Your sky, as on the hub's dashboard; the quiet field when there is nothing of yours to draw. */
14 +
fun skyScene(sky: UserSky?, still: Boolean): Scene =
15 +
    if (sky == null || sky.repos.isEmpty()) Scene.adrift(still) else Scene.sky(sky.repos, skyEvents(sky), 0f, 0f, still)
added rust-core/src/backdrop/adrift.rs +134 −0
1 +
// Copied from OxygenHub crates/backdrop/src/adrift.rs (6dc080f6) by
2 +
// scripts/sync-backdrop.sh: change it there, never here.
3 +
//! Adrift (docs/backgrounds.md §2): the not-found and server-error pages.
4 +
//! A quiet cluster in the margin, and one mark drifting away from it, as
5 +
//! the page drifted from what was asked for. It drifts for a minute, fades
6 +
//! and sets out again. No data.
7 +
8 +
use crate::backdrop::field::{knot, margins, unit, Dot, Field, Orbit, ZOOM};
9 +
use crate::backdrop::scene::Scene;
10 +
use crate::backdrop::sky::{hash, Colours};
11 +
12 +
const CLUSTER: usize = 160;
13 +
/// The lost mark's speed, and how long one drift lasts, fade included.
14 +
const DRIFT_SPEED: f32 = 7.0;
15 +
const DRIFT_S: f32 = 70.0;
16 +
const FADE_S: f32 = 5.0;
17 +
18 +
pub struct Adrift {
19 +
    field: Field,
20 +
    orbits: Vec<Orbit>,
21 +
    centre: (f32, f32),
22 +
    w: f32,
23 +
    h: f32,
24 +
    t: f32,
25 +
}
26 +
27 +
impl Adrift {
28 +
    pub fn new(still: bool) -> Adrift {
29 +
        let mut field = Field::new(still);
30 +
        let orbits = knot("adrift", CLUSTER);
31 +
        for k in 0..CLUSTER {
32 +
            let h = hash(&format!("adrift@{k}"));
33 +
            let mut d = Dot::new(0.0, 0.0, 2.0 * ZOOM, if k % 7 == 0 { 2.0 } else { 0.0 });
34 +
            d.bright = 0.5 + 0.5 * unit(h, 48);
35 +
            d.group = 0;
36 +
            d.order = k as u32;
37 +
            field.dots.push(d);
38 +
        }
39 +
        // The cluster's core, and the lost mark, a little larger.
40 +
        field.dots.push(Dot::new(0.0, 0.0, 8.0, 4.0));
41 +
        let mut lost = Dot::new(0.0, 0.0, 3.4 * ZOOM, 0.0);
42 +
        lost.bright = 1.0;
43 +
        field.dots.push(lost);
44 +
        let mut a = Adrift { field, orbits, centre: (0.0, 0.0), w: 0.0, h: 0.0, t: 12.0 };
45 +
        a.place();
46 +
        a
47 +
    }
48 +
49 +
    fn place(&mut self) {
50 +
        let (cx, cy) = self.centre;
51 +
        for (i, o) in self.orbits.iter().enumerate() {
52 +
            let (x, y) = o.at((cx, cy), self.t);
53 +
            self.field.dots[i].x = x;
54 +
            self.field.dots[i].y = y;
55 +
        }
56 +
        let core = &mut self.field.dots[CLUSTER];
57 +
        core.x = cx;
58 +
        core.y = cy;
59 +
        // The lost mark leaves up and to the right, fading at the end of
60 +
        // each drift and setting out again from the cluster's edge.
61 +
        let p = self.t % DRIFT_S;
62 +
        let dir: f32 = -0.35;
63 +
        let from = self.orbits.iter().map(|o| o.radius).fold(0.0, f32::max) + 14.0;
64 +
        let lost = &mut self.field.dots[CLUSTER + 1];
65 +
        lost.x = cx + dir.cos() * (from + p * DRIFT_SPEED);
66 +
        lost.y = cy + dir.sin() * (from + p * DRIFT_SPEED);
67 +
        lost.bright = ((DRIFT_S - p) / FADE_S).min(p / FADE_S).clamp(0.0, 1.0);
68 +
    }
69 +
}
70 +
71 +
impl Scene for Adrift {
72 +
    fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) {
73 +
        self.w = w;
74 +
        self.h = h;
75 +
        self.centre = match margins(w, avoid) {
76 +
            Some((l, _)) => (l * 0.45, h * 0.62),
77 +
            None => (w * 0.15, h * 0.7),
78 +
        };
79 +
        self.place();
80 +
    }
81 +
    fn point(&mut self, at: Option<(f32, f32)>) {
82 +
        self.field.point(at);
83 +
    }
84 +
    fn step(&mut self, dt: f32) {
85 +
        if !self.field.still {
86 +
            self.t += dt.clamp(0.0, 0.1);
87 +
        }
88 +
        self.place();
89 +
        self.field.light(dt);
90 +
    }
91 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
92 +
        self.field.instances(c, out)
93 +
    }
94 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>) {
95 +
        self.field.links(c, out)
96 +
    }
97 +
    fn label(&self) -> Option<(&str, f32, f32)> {
98 +
        None
99 +
    }
100 +
    fn busy(&self) -> bool {
101 +
        self.field.easing()
102 +
    }
103 +
    fn still(&self) -> bool {
104 +
        self.field.still
105 +
    }
106 +
    fn size(&self) -> (f32, f32) {
107 +
        (self.w, self.h)
108 +
    }
109 +
}
110 +
111 +
#[cfg(test)]
112 +
mod tests {
113 +
    use super::*;
114 +
115 +
    #[test]
116 +
    fn one_mark_drifts_away_from_the_cluster_and_sets_out_again() {
117 +
        let mut a = Adrift::new(false);
118 +
        a.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
119 +
        assert!(a.centre.0 < 707.0, "the cluster sits in the margin");
120 +
        let dist = |a: &Adrift| {
121 +
            let l = &a.field.dots[CLUSTER + 1];
122 +
            (l.x - a.centre.0).hypot(l.y - a.centre.1)
123 +
        };
124 +
        let early = dist(&a);
125 +
        for _ in 0..200 {
126 +
            a.step(0.1);
127 +
        }
128 +
        assert!(dist(&a) > early + 100.0, "it drifts away");
129 +
        for _ in 0..500 {
130 +
            a.step(0.1);
131 +
        }
132 +
        assert!(dist(&a) < early + 100.0, "and sets out again");
133 +
    }
134 +
}
added rust-core/src/backdrop/field.rs +282 −0
1 +
// Copied from OxygenHub crates/backdrop/src/field.rs (6dc080f6) by
2 +
// scripts/sync-backdrop.sh: change it there, never here.
3 +
//! What every scene but the sky shares (docs/backgrounds.md §4): a field
4 +
//! of dots that its scene places each frame, lit by the cursor without
5 +
//! being moved, joined within a group in order, and named things that a
6 +
//! resting cursor names. Dots of a scene with a status (Diffusion's pills)
7 +
//! take the page's status colours; every other dot the backdrop tokens.
8 +
9 +
use crate::backdrop::sky::{Colours, Rgba};
10 +
11 +
/// How far the cursor lights dots, in CSS pixels, as in the sky.
12 +
pub const LIGHT_RADIUS: f32 = 170.0;
13 +
const LIT_IN: f32 = 0.15;
14 +
const LIT_OUT: f32 = 0.6;
15 +
/// The longest link drawn between two dots of a group.
16 +
const LINK_MAX: f32 = 85.0;
17 +
const LINK_REST: f32 = 2.0;
18 +
const LABEL_NEAR: f32 = 110.0;
19 +
const LABEL_REST: f32 = 0.4;
20 +
21 +
/// A dot's colour: the backdrop's own, or a status where the data has one.
22 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23 +
pub enum Tone {
24 +
    Mark,
25 +
    Ok,
26 +
    Warn,
27 +
    Bad,
28 +
}
29 +
30 +
impl Tone {
31 +
    /// A Diffusion pill half's tone as the page names it.
32 +
    pub fn from_name(s: &str) -> Tone {
33 +
        match s {
34 +
            "green" => Tone::Ok,
35 +
            "amber" => Tone::Warn,
36 +
            "red" => Tone::Bad,
37 +
            _ => Tone::Mark,
38 +
        }
39 +
    }
40 +
}
41 +
42 +
/// One drawn dot.
43 +
#[derive(Clone, Copy, Debug)]
44 +
pub struct Dot {
45 +
    pub x: f32,
46 +
    pub y: f32,
47 +
    /// Radius in CSS pixels.
48 +
    pub size: f32,
49 +
    /// The shader's shape (sky::Kind::shape).
50 +
    pub shape: f32,
51 +
    pub tone: Tone,
52 +
    /// 0 to 1: how strongly it shows at rest.
53 +
    pub bright: f32,
54 +
    pub lit: f32,
55 +
    /// Dots of one group are joined in `order` when lit; `NONE` never.
56 +
    pub group: u32,
57 +
    pub order: u32,
58 +
}
59 +
60 +
impl Dot {
61 +
    pub const NONE: u32 = u32::MAX;
62 +
63 +
    pub fn new(x: f32, y: f32, size: f32, shape: f32) -> Dot {
64 +
        Dot { x, y, size, shape, tone: Tone::Mark, bright: 1.0, lit: 0.0, group: Dot::NONE, order: 0 }
65 +
    }
66 +
}
67 +
68 +
/// Something with a name the cursor can rest by.
69 +
#[derive(Clone, Debug)]
70 +
pub struct Named {
71 +
    pub name: String,
72 +
    pub x: f32,
73 +
    pub y: f32,
74 +
    /// How far its dots reach: the label sits just above.
75 +
    pub reach: f32,
76 +
}
77 +
78 +
pub struct Field {
79 +
    pub dots: Vec<Dot>,
80 +
    pub named: Vec<Named>,
81 +
    pub cursor: Option<(f32, f32)>,
82 +
    pub rested: f32,
83 +
    pub still: bool,
84 +
    link_fade: f32,
85 +
}
86 +
87 +
impl Field {
88 +
    pub fn new(still: bool) -> Field {
89 +
        Field { dots: Vec::new(), named: Vec::new(), cursor: None, rested: 0.0, still, link_fade: 0.0 }
90 +
    }
91 +
92 +
    pub fn point(&mut self, at: Option<(f32, f32)>) {
93 +
        self.cursor = at;
94 +
        self.rested = 0.0;
95 +
    }
96 +
97 +
    /// Light the dots near the cursor, after the scene has placed them.
98 +
    pub fn light(&mut self, dt: f32) {
99 +
        let dt = dt.clamp(0.0, 0.1);
100 +
        self.rested += dt;
101 +
        let cursor = self.cursor;
102 +
        let still = self.still;
103 +
        for d in &mut self.dots {
104 +
            let target = match cursor {
105 +
                Some((x, y)) if (d.x - x).hypot(d.y - y) <= LIGHT_RADIUS => 1.0,
106 +
                _ => 0.0,
107 +
            };
108 +
            d.lit = if still {
109 +
                target
110 +
            } else if target > d.lit {
111 +
                (d.lit + dt / LIT_IN).min(target)
112 +
            } else {
113 +
                (d.lit - dt / LIT_OUT).max(target)
114 +
            };
115 +
        }
116 +
        let fade_to = if cursor.is_some() && self.rested < LINK_REST { 1.0 } else { 0.0 };
117 +
        self.link_fade = if still {
118 +
            fade_to
119 +
        } else if fade_to > self.link_fade {
120 +
            (self.link_fade + dt / LIT_IN).min(1.0)
121 +
        } else {
122 +
            (self.link_fade - dt / LIT_OUT).max(0.0)
123 +
        };
124 +
    }
125 +
126 +
    /// Whether lighting or links are still easing.
127 +
    pub fn easing(&self) -> bool {
128 +
        self.dots.iter().any(|d| d.lit > 0.0 && d.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0)
129 +
    }
130 +
131 +
    fn colour(c: &Colours, tone: Tone, bright: f32, lit: f32) -> Rgba {
132 +
        let (rest, hot) = match tone {
133 +
            Tone::Mark => (c.mark, c.lit),
134 +
            Tone::Ok => (with_alpha(c.ok, 0.45), with_alpha(c.ok, 0.95)),
135 +
            Tone::Warn => (with_alpha(c.warn, 0.45), with_alpha(c.warn, 0.95)),
136 +
            Tone::Bad => (with_alpha(c.bad, 0.45), with_alpha(c.bad, 0.95)),
137 +
        };
138 +
        let rest = [rest[0], rest[1], rest[2], rest[3] * bright];
139 +
        let t = lit.clamp(0.0, 1.0);
140 +
        [
141 +
            rest[0] + (hot[0] - rest[0]) * t,
142 +
            rest[1] + (hot[1] - rest[1]) * t,
143 +
            rest[2] + (hot[2] - rest[2]) * t,
144 +
            rest[3] + (hot[3] - rest[3]) * t,
145 +
        ]
146 +
    }
147 +
148 +
    /// 8 floats a dot, as sky::Sky::instances lays them out.
149 +
    pub fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
150 +
        out.clear();
151 +
        for d in &self.dots {
152 +
            let col = Field::colour(c, d.tone, d.bright, d.lit);
153 +
            let size = d.size * (1.0 + 0.35 * d.lit);
154 +
            out.extend_from_slice(&[d.x, d.y, size, d.shape, col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]);
155 +
        }
156 +
    }
157 +
158 +
    /// Lit dots of a group joined in order, where near each other.
159 +
    pub fn links(&self, c: &Colours, out: &mut Vec<f32>) {
160 +
        out.clear();
161 +
        if self.link_fade <= 0.0 {
162 +
            return;
163 +
        }
164 +
        let mut lit: Vec<&Dot> = self.dots.iter().filter(|d| d.group != Dot::NONE && d.lit > 0.05).collect();
165 +
        lit.sort_by_key(|d| (d.group, d.order));
166 +
        for pair in lit.windows(2) {
167 +
            let (a, b) = (pair[0], pair[1]);
168 +
            if a.group != b.group || (a.x - b.x).hypot(a.y - b.y) > LINK_MAX {
169 +
                continue;
170 +
            }
171 +
            let alpha = c.link[3] * a.lit.min(b.lit) * self.link_fade;
172 +
            let col = [c.link[0] * alpha, c.link[1] * alpha, c.link[2] * alpha, alpha];
173 +
            out.extend_from_slice(&[a.x, a.y, col[0], col[1], col[2], col[3], b.x, b.y, col[0], col[1], col[2], col[3]]);
174 +
        }
175 +
    }
176 +
177 +
    /// The name above the named thing a resting cursor is by.
178 +
    pub fn label(&self) -> Option<(&str, f32, f32)> {
179 +
        let (x, y) = self.cursor?;
180 +
        if self.rested < LABEL_REST {
181 +
            return None;
182 +
        }
183 +
        self.named
184 +
            .iter()
185 +
            .map(|n| (n, (n.x - x).hypot(n.y - y)))
186 +
            .filter(|(n, d)| *d <= LABEL_NEAR.max(n.reach))
187 +
            .min_by(|a, b| a.1.total_cmp(&b.1))
188 +
            .map(|(n, _)| (n.name.as_str(), n.x, n.y - n.reach - 10.0))
189 +
    }
190 +
}
191 +
192 +
fn with_alpha(c: Rgba, a: f32) -> Rgba {
193 +
    [c[0], c[1], c[2], a]
194 +
}
195 +
196 +
/// A number from 0 to 1 from bits of a hash (sky::hash).
197 +
pub fn unit(h: u64, shift: u32) -> f32 {
198 +
    ((h >> shift) & 0xffff) as f32 / 65536.0
199 +
}
200 +
201 +
/// One dot of a knot: its orbit about the knot's centre.
202 +
#[derive(Clone, Copy, Debug)]
203 +
pub struct Orbit {
204 +
    pub radius: f32,
205 +
    pub angle: f32,
206 +
    /// Radians a second, signed.
207 +
    pub speed: f32,
208 +
}
209 +
210 +
impl Orbit {
211 +
    pub fn at(&self, centre: (f32, f32), t: f32) -> (f32, f32) {
212 +
        let a = self.angle + self.speed * t;
213 +
        (centre.0 + a.cos() * self.radius, centre.1 + a.sin() * self.radius)
214 +
    }
215 +
}
216 +
217 +
/// `n` dots as a knot like a sky's cluster (sky::Sky, Andy's 2026-10-13
218 +
/// tuning): close lanes, more of them the busier, drawn 1.4 times larger,
219 +
/// turning one way at the sky's slow speed. `seed` makes it stable.
220 +
pub fn knot(seed: &str, n: usize) -> Vec<Orbit> {
221 +
    let lanes = (6 + n / 30).min(14) as u32;
222 +
    let dir = if crate::backdrop::sky::hash(seed) & 1 == 0 { 1.0 } else { -1.0 };
223 +
    (0..n as u32)
224 +
        .map(|k| {
225 +
            let h = crate::backdrop::sky::hash(&format!("{seed}#{k}"));
226 +
            let radius = (16.0 + (k % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * 1.4;
227 +
            let speed = dir * (crate::backdrop::sky::MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
228 +
            Orbit { radius, angle: unit(h, 32) * std::f32::consts::TAU, speed }
229 +
        })
230 +
        .collect()
231 +
}
232 +
233 +
/// A dot's size drawn as the sky draws its marks (1.4 times larger).
234 +
pub const ZOOM: f32 = 1.4;
235 +
236 +
/// The page's margins beside its content column, when both are wide
237 +
/// enough to hold something: (left width, right start).
238 +
pub fn margins(w: f32, avoid: Option<(f32, f32)>) -> Option<(f32, f32)> {
239 +
    let (x0, x1) = avoid?;
240 +
    (x0 >= 180.0 && w - x1 >= 180.0).then_some((x0, x1))
241 +
}
242 +
243 +
#[cfg(test)]
244 +
mod tests {
245 +
    use super::*;
246 +
247 +
    #[test]
248 +
    fn the_cursor_lights_and_joins_a_group_and_names_what_it_rests_by() {
249 +
        let mut f = Field::new(false);
250 +
        for i in 0..4 {
251 +
            let mut d = Dot::new(100.0 + i as f32 * 20.0, 100.0, 3.0, 0.0);
252 +
            d.group = 0;
253 +
            d.order = i;
254 +
            f.dots.push(d);
255 +
        }
256 +
        f.dots.push(Dot::new(900.0, 900.0, 3.0, 0.0));
257 +
        f.named.push(Named { name: "earth-1".into(), x: 130.0, y: 100.0, reach: 40.0 });
258 +
        f.point(Some((130.0, 100.0)));
259 +
        for _ in 0..30 {
260 +
            f.light(1.0 / 60.0);
261 +
        }
262 +
        assert!(f.dots[..4].iter().all(|d| d.lit == 1.0) && f.dots[4].lit == 0.0);
263 +
        let c = Colours::default();
264 +
        let mut links = Vec::new();
265 +
        f.links(&c, &mut links);
266 +
        assert_eq!(links.len(), 3 * 12, "three links in order, none to the dot far away");
267 +
        assert_eq!(f.label().map(|l| l.0), Some("earth-1"));
268 +
        let mut inst = Vec::new();
269 +
        f.instances(&c, &mut inst);
270 +
        assert_eq!(inst.len(), 5 * 8);
271 +
    }
272 +
273 +
    #[test]
274 +
    fn status_tones_come_from_the_pill_names() {
275 +
        assert_eq!(Tone::from_name("green"), Tone::Ok);
276 +
        assert_eq!(Tone::from_name("amber"), Tone::Warn);
277 +
        assert_eq!(Tone::from_name("red"), Tone::Bad);
278 +
        assert_eq!(Tone::from_name("grey"), Tone::Mark);
279 +
        assert_eq!(margins(2560.0, Some((707.0, 1838.0))), Some((707.0, 1838.0)));
280 +
        assert_eq!(margins(1200.0, Some((100.0, 1100.0))), None);
281 +
    }
282 +
}
added rust-core/src/backdrop/mark.rs +151 −0
1 +
// Copied from OxygenHub crates/backdrop/src/mark.rs (6dc080f6) by
2 +
// scripts/sync-backdrop.sh: change it there, never here.
3 +
//! The mark (docs/backgrounds.md §2): sign in, register and device sign-in.
4 +
//! The Oxygen mark's two rings drawn in dots, turning opposite ways so the
5 +
//! stream runs through the middle like the mark's infinity, each with its
6 +
//! core. Brand only: no data.
7 +
8 +
use crate::backdrop::field::{unit, Dot, Field, ZOOM};
9 +
use crate::backdrop::scene::Scene;
10 +
use crate::backdrop::sky::{hash, Colours};
11 +
use std::f32::consts::TAU;
12 +
13 +
/// Dots on the two rings.
14 +
const DOTS: usize = 1400;
15 +
/// How fast the rings turn, at their rim, in CSS pixels a second.
16 +
const RIM_SPEED: f32 = 9.0;
17 +
18 +
struct Bead {
19 +
    ring: usize,
20 +
    angle: f32,
21 +
    /// Its distance off the ring's line, and its breathing's phase.
22 +
    off: f32,
23 +
    phase: f32,
24 +
}
25 +
26 +
pub struct Mark {
27 +
    field: Field,
28 +
    beads: Vec<Bead>,
29 +
    w: f32,
30 +
    h: f32,
31 +
    t: f32,
32 +
}
33 +
34 +
impl Mark {
35 +
    pub fn new(still: bool) -> Mark {
36 +
        let mut field = Field::new(still);
37 +
        let mut beads = Vec::new();
38 +
        for i in 0..DOTS {
39 +
            let h = hash(&format!("mark#{i}"));
40 +
            let ring = i % 2;
41 +
            // Off the ring's line by a bell-ish spread, most near it.
42 +
            let off = (unit(h, 16) + unit(h, 32) - 1.0) * 18.0;
43 +
            beads.push(Bead { ring, angle: unit(h, 0) * TAU, off, phase: unit(h, 48) * TAU });
44 +
            let mut d = Dot::new(0.0, 0.0, (1.5 + unit(h, 40) * 1.6) * ZOOM, if i % 11 == 0 { 1.0 } else { 0.0 });
45 +
            d.bright = 0.55 + 0.45 * unit(h, 24);
46 +
            d.group = ring as u32;
47 +
            field.dots.push(d);
48 +
        }
49 +
        // Join each ring's beads in the order round it.
50 +
        for ring in 0..2 {
51 +
            let mut idx: Vec<usize> = (0..DOTS).filter(|&i| beads[i].ring == ring).collect();
52 +
            idx.sort_by(|&a, &b| beads[a].angle.total_cmp(&beads[b].angle));
53 +
            for (k, i) in idx.into_iter().enumerate() {
54 +
                field.dots[i].order = k as u32;
55 +
            }
56 +
        }
57 +
        // The two cores.
58 +
        for _ in 0..2 {
59 +
            field.dots.push(Dot::new(0.0, 0.0, 7.0, 0.0));
60 +
        }
61 +
        let mut m = Mark { field, beads, w: 0.0, h: 0.0, t: 0.0 };
62 +
        m.place();
63 +
        m
64 +
    }
65 +
66 +
    /// The rings' radius and centres for this view: large enough to frame
67 +
    /// the page, out into its margins, their centres 1.1 radii either side
68 +
    /// of the middle, as the mark draws them.
69 +
    fn rings(&self) -> (f32, [(f32, f32); 2]) {
70 +
        let r = (self.w * 0.2).min(self.h * 0.42).clamp(110.0, 560.0);
71 +
        let (cx, cy) = (self.w / 2.0, self.h / 2.0);
72 +
        (r, [(cx - r * 1.1, cy), (cx + r * 1.1, cy)])
73 +
    }
74 +
75 +
    fn place(&mut self) {
76 +
        let (r, centres) = self.rings();
77 +
        let spin = RIM_SPEED / r * self.t;
78 +
        for (i, b) in self.beads.iter().enumerate() {
79 +
            // The left ring turns one way and the right the other.
80 +
            let a = b.angle + if b.ring == 0 { spin } else { -spin };
81 +
            let rr = r + b.off + 3.0 * (self.t * 0.4 + b.phase).sin();
82 +
            let (cx, cy) = centres[b.ring];
83 +
            self.field.dots[i].x = cx + a.cos() * rr;
84 +
            self.field.dots[i].y = cy + a.sin() * rr;
85 +
        }
86 +
        for (k, (cx, cy)) in centres.iter().enumerate() {
87 +
            let d = &mut self.field.dots[DOTS + k];
88 +
            d.x = *cx;
89 +
            d.y = *cy;
90 +
        }
91 +
    }
92 +
}
93 +
94 +
impl Scene for Mark {
95 +
    fn resize(&mut self, w: f32, h: f32, _avoid: Option<(f32, f32)>) {
96 +
        self.w = w;
97 +
        self.h = h;
98 +
        self.place();
99 +
    }
100 +
    fn point(&mut self, at: Option<(f32, f32)>) {
101 +
        self.field.point(at);
102 +
    }
103 +
    fn step(&mut self, dt: f32) {
104 +
        if !self.field.still {
105 +
            self.t += dt.clamp(0.0, 0.1);
106 +
        }
107 +
        self.place();
108 +
        self.field.light(dt);
109 +
    }
110 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
111 +
        self.field.instances(c, out)
112 +
    }
113 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>) {
114 +
        self.field.links(c, out)
115 +
    }
116 +
    fn label(&self) -> Option<(&str, f32, f32)> {
117 +
        None
118 +
    }
119 +
    fn busy(&self) -> bool {
120 +
        self.field.easing()
121 +
    }
122 +
    fn still(&self) -> bool {
123 +
        self.field.still
124 +
    }
125 +
    fn size(&self) -> (f32, f32) {
126 +
        (self.w, self.h)
127 +
    }
128 +
}
129 +
130 +
#[cfg(test)]
131 +
mod tests {
132 +
    use super::*;
133 +
134 +
    #[test]
135 +
    fn two_rings_turn_opposite_ways_about_their_cores() {
136 +
        let mut m = Mark::new(false);
137 +
        m.resize(1600.0, 900.0, None);
138 +
        let (r, centres) = m.rings();
139 +
        assert!(centres[0].0 < 800.0 && centres[1].0 > 800.0);
140 +
        for (i, b) in m.beads.iter().enumerate() {
141 +
            let (cx, cy) = centres[b.ring];
142 +
            let d = (m.field.dots[i].x - cx).hypot(m.field.dots[i].y - cy);
143 +
            assert!((d - r).abs() <= 22.0, "on its ring: {d} against {r}");
144 +
        }
145 +
        let before = (m.field.dots[0].x, m.field.dots[0].y);
146 +
        m.step(0.1);
147 +
        let moved = (m.field.dots[0].x - before.0).hypot(m.field.dots[0].y - before.1);
148 +
        assert!(moved > 0.0 && moved < 2.0, "slow: {moved} px in a tenth of a second");
149 +
        assert!(m.label().is_none(), "brand only: nothing to name");
150 +
    }
151 +
}
added rust-core/src/backdrop/mod.rs +11 −0
1 +
//! The hub's backdrop scenes (OxygenHub crates/backdrop), copied by
2 +
//! scripts/sync-backdrop.sh: the website's living backgrounds, stepped here
3 +
//! and drawn by the app (ui/backdrop).
4 +
#![allow(dead_code)]
5 +
pub mod adrift;
6 +
pub mod field;
7 +
pub mod mark;
8 +
pub mod ripples;
9 +
pub mod scene;
10 +
pub mod sky;
11 +
pub mod swarm;
added rust-core/src/backdrop/ripples.rs +245 −0
1 +
// Copied from OxygenHub crates/backdrop/src/ripples.rs (6dc080f6) by
2 +
// scripts/sync-backdrop.sh: change it there, never here.
3 +
//! Ripples (docs/backgrounds.md §2): a repository's Diffusion tab. The
4 +
//! repository in one margin, the upstreams it takes from beside it and the
5 +
//! consumers it diffuses to in the other; its changes flow out along arcs
6 +
//! to its consumers and its upstreams' flow in, each stream and node in
7 +
//! the status of its Diffusion pill, and a ripple widens from the
8 +
//! repository every few seconds. The data is the page's own.
9 +
10 +
use crate::backdrop::field::{knot, margins, unit, Dot, Field, Named, Orbit, Tone, ZOOM};
11 +
use crate::backdrop::scene::Scene;
12 +
use crate::backdrop::sky::Colours;
13 +
use std::f32::consts::{PI, TAU};
14 +
15 +
/// Dots in the repository's knot and in each other's, as dense as a sky's
16 +
/// clusters.
17 +
const KNOT_REPO: usize = 90;
18 +
const KNOT: usize = 60;
19 +
/// Dots flowing along each stream, and their speed in CSS pixels a second:
20 +
/// a stream is a flow, quicker than the drift of marks.
21 +
const FLOW: usize = 28;
22 +
const FLOW_SPEED: f32 = 40.0;
23 +
/// A ripple every this many seconds, widening this far over this long.
24 +
const RIPPLE_EVERY: f32 = 5.0;
25 +
const RIPPLE_R: f32 = 280.0;
26 +
const RIPPLE_S: f32 = 3.2;
27 +
const RIPPLE_DOTS: usize = 72;
28 +
29 +
/// One repository on the page: its name and the tone its dots take.
30 +
pub struct Node {
31 +
    pub name: String,
32 +
    pub tone: Tone,
33 +
}
34 +
35 +
pub struct Ripples {
36 +
    field: Field,
37 +
    /// The repository first, then its upstreams, then its consumers.
38 +
    nodes: Vec<Node>,
39 +
    ups: usize,
40 +
    pos: Vec<(f32, f32)>,
41 +
    /// Each knot's dots: its node, and its orbit.
42 +
    orbits: Vec<(usize, Orbit)>,
43 +
    /// Streams: from, to, and their dots' first index.
44 +
    streams: Vec<(usize, usize, usize)>,
45 +
    ripple_at: usize,
46 +
    w: f32,
47 +
    h: f32,
48 +
    t: f32,
49 +
}
50 +
51 +
impl Ripples {
52 +
    pub fn new(repo: String, upstreams: Vec<Node>, consumers: Vec<Node>, still: bool) -> Ripples {
53 +
        let ups = upstreams.len();
54 +
        let mut nodes = vec![Node { name: repo, tone: Tone::Mark }];
55 +
        nodes.extend(upstreams);
56 +
        nodes.extend(consumers);
57 +
        let mut field = Field::new(still);
58 +
        let mut orbits = Vec::new();
59 +
        for (i, n) in nodes.iter().enumerate() {
60 +
            let mut core = Dot::new(0.0, 0.0, if i == 0 { 9.0 } else { 7.0 }, 4.0);
61 +
            core.tone = n.tone;
62 +
            field.dots.push(core);
63 +
        }
64 +
        for (i, n) in nodes.iter().enumerate() {
65 +
            for (k, o) in knot(&n.name, if i == 0 { KNOT_REPO } else { KNOT }).into_iter().enumerate() {
66 +
                let mut d = Dot::new(0.0, 0.0, 2.0 * ZOOM, if k % 9 == 0 { 1.0 } else { 0.0 });
67 +
                d.tone = n.tone;
68 +
                d.group = i as u32;
69 +
                d.order = k as u32;
70 +
                d.bright = 0.55 + 0.45 * unit(crate::backdrop::sky::hash(&format!("{}@{k}", n.name)), 0);
71 +
                field.dots.push(d);
72 +
                orbits.push((i, o));
73 +
            }
74 +
        }
75 +
        // Upstreams flow into the repository; the repository flows out to
76 +
        // each consumer, the stream in the consumer's tone.
77 +
        let mut streams = Vec::new();
78 +
        for (i, node) in nodes.iter().enumerate() {
79 +
            let (from, to) = match i {
80 +
                0 => continue,
81 +
                i if i <= ups => (i, 0),
82 +
                i => (0, i),
83 +
            };
84 +
            streams.push((from, to, field.dots.len()));
85 +
            for _ in 0..FLOW {
86 +
                let mut d = Dot::new(0.0, 0.0, 1.8 * ZOOM, 0.0);
87 +
                d.tone = node.tone;
88 +
                field.dots.push(d);
89 +
            }
90 +
        }
91 +
        let ripple_at = field.dots.len();
92 +
        for _ in 0..RIPPLE_DOTS {
93 +
            let mut d = Dot::new(0.0, 0.0, 1.6 * ZOOM, 0.0);
94 +
            d.bright = 0.0;
95 +
            field.dots.push(d);
96 +
        }
97 +
        let mut r = Ripples { field, nodes, ups, pos: Vec::new(), orbits, streams, ripple_at, w: 0.0, h: 0.0, t: 0.0 };
98 +
        r.layout(None);
99 +
        r
100 +
    }
101 +
102 +
    fn layout(&mut self, avoid: Option<(f32, f32)>) {
103 +
        let (w, h) = (self.w, self.h);
104 +
        let n = self.nodes.len();
105 +
        let cons = n - 1 - self.ups;
106 +
        let spread = |i: usize, of: usize, top: f32, bottom: f32| {
107 +
            if of <= 1 {
108 +
                (top + bottom) / 2.0
109 +
            } else {
110 +
                top + (bottom - top) * i as f32 / (of - 1) as f32
111 +
            }
112 +
        };
113 +
        let (repo_x, up_x, con_x) = match margins(w, avoid) {
114 +
            Some((l, x1)) => (l * 0.62, l * 0.22, x1 + (w - x1) * 0.45),
115 +
            None => (w * 0.12, w * 0.04, w * 0.88),
116 +
        };
117 +
        self.pos = (0..n)
118 +
            .map(|i| match i {
119 +
                0 => (repo_x, h * 0.5),
120 +
                i if i <= self.ups => (up_x, spread(i - 1, self.ups, h * 0.25, h * 0.75)),
121 +
                i => (con_x, spread(i - 1 - self.ups, cons, h * 0.15, h * 0.85)),
122 +
            })
123 +
            .collect();
124 +
        self.field.named =
125 +
            self.nodes.iter().zip(&self.pos).map(|(nd, &(x, y))| Named { name: nd.name.clone(), x, y, reach: 90.0 }).collect();
126 +
    }
127 +
128 +
    fn place(&mut self) {
129 +
        let n = self.nodes.len();
130 +
        for (i, &(x, y)) in self.pos.iter().enumerate() {
131 +
            self.field.dots[i].x = x;
132 +
            self.field.dots[i].y = y;
133 +
        }
134 +
        for (k, (node, o)) in self.orbits.iter().enumerate() {
135 +
            let (x, y) = o.at(self.pos[*node], self.t);
136 +
            self.field.dots[n + k].x = x;
137 +
            self.field.dots[n + k].y = y;
138 +
        }
139 +
        // Streams: a gentle arc from each source to its destination, its
140 +
        // dots evenly spaced and moving along it, faded at the ends.
141 +
        for &(from, to, first) in &self.streams {
142 +
            let (a, b) = (self.pos[from], self.pos[to]);
143 +
            let len = (b.0 - a.0).hypot(b.1 - a.1).max(1.0);
144 +
            let ctrl = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0 - len * 0.18);
145 +
            for k in 0..FLOW {
146 +
                let s = (k as f32 / FLOW as f32 + self.t * FLOW_SPEED / len).fract();
147 +
                let u = 1.0 - s;
148 +
                let d = &mut self.field.dots[first + k];
149 +
                d.x = u * u * a.0 + 2.0 * u * s * ctrl.0 + s * s * b.0;
150 +
                d.y = u * u * a.1 + 2.0 * u * s * ctrl.1 + s * s * b.1;
151 +
                d.bright = (s * PI).sin();
152 +
            }
153 +
        }
154 +
        // The ripple: a ring of dots widening from the repository.
155 +
        let p = (self.t % RIPPLE_EVERY) / RIPPLE_S;
156 +
        let (x, y) = self.pos[0];
157 +
        for k in 0..RIPPLE_DOTS {
158 +
            let d = &mut self.field.dots[self.ripple_at + k];
159 +
            let a = k as f32 / RIPPLE_DOTS as f32 * TAU;
160 +
            let r = 40.0 + RIPPLE_R * p.min(1.0);
161 +
            d.x = x + a.cos() * r;
162 +
            d.y = y + a.sin() * r;
163 +
            d.bright = if self.field.still || p >= 1.0 { 0.0 } else { 0.8 * (1.0 - p) };
164 +
        }
165 +
    }
166 +
}
167 +
168 +
impl Scene for Ripples {
169 +
    fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) {
170 +
        self.w = w;
171 +
        self.h = h;
172 +
        self.layout(avoid);
173 +
        self.place();
174 +
    }
175 +
    fn point(&mut self, at: Option<(f32, f32)>) {
176 +
        self.field.point(at);
177 +
    }
178 +
    fn step(&mut self, dt: f32) {
179 +
        if !self.field.still {
180 +
            self.t += dt.clamp(0.0, 0.1);
181 +
        }
182 +
        self.place();
183 +
        self.field.light(dt);
184 +
    }
185 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
186 +
        self.field.instances(c, out)
187 +
    }
188 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>) {
189 +
        self.field.links(c, out)
190 +
    }
191 +
    fn label(&self) -> Option<(&str, f32, f32)> {
192 +
        self.field.label()
193 +
    }
194 +
    fn busy(&self) -> bool {
195 +
        self.field.easing()
196 +
    }
197 +
    fn still(&self) -> bool {
198 +
        self.field.still
199 +
    }
200 +
    fn size(&self) -> (f32, f32) {
201 +
        (self.w, self.h)
202 +
    }
203 +
}
204 +
205 +
#[cfg(test)]
206 +
mod tests {
207 +
    use super::*;
208 +
209 +
    fn node(name: &str, tone: Tone) -> Node {
210 +
        Node { name: name.into(), tone }
211 +
    }
212 +
213 +
    #[test]
214 +
    fn upstreams_flow_in_and_consumers_out_in_their_pills_tones() {
215 +
        let mut r = Ripples::new(
216 +
            "andy/api".into(),
217 +
            vec![node("andy/base", Tone::Ok)],
218 +
            vec![node("andy/game", Tone::Ok), node("andy/other", Tone::Bad)],
219 +
            false,
220 +
        );
221 +
        r.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
222 +
        assert!(r.pos[0].0 < 707.0 && r.pos[1].0 < r.pos[0].0, "the repository and its upstream in the left margin");
223 +
        assert!(r.pos[2].0 > 1838.0 && r.pos[3].0 > 1838.0, "its consumers in the right");
224 +
        let knots = 4 + KNOT_REPO + 3 * KNOT;
225 +
        assert_eq!(r.streams, vec![(1, 0, knots), (0, 2, knots + FLOW), (0, 3, knots + 2 * FLOW)]);
226 +
        let bad_stream = r.streams[2].2;
227 +
        assert!(r.field.dots[bad_stream..bad_stream + FLOW].iter().all(|d| d.tone == Tone::Bad), "a failing consumer's stream is red");
228 +
        let before = r.field.dots[bad_stream].x;
229 +
        r.step(0.1);
230 +
        assert_ne!(before, r.field.dots[bad_stream].x, "streams flow");
231 +
        r.point(Some(r.pos[3]));
232 +
        for _ in 0..30 {
233 +
            r.step(0.02);
234 +
        }
235 +
        assert_eq!(r.label().map(|l| l.0), Some("andy/other"));
236 +
    }
237 +
238 +
    #[test]
239 +
    fn still_keeps_the_ripple_away() {
240 +
        let mut r = Ripples::new("andy/api".into(), vec![], vec![node("andy/game", Tone::Mark)], true);
241 +
        r.resize(1600.0, 900.0, None);
242 +
        r.step(0.1);
243 +
        assert!(r.field.dots[r.ripple_at..].iter().all(|d| d.bright == 0.0));
244 +
    }
245 +
}
added rust-core/src/backdrop/scene.rs +62 −0
1 +
// Copied from OxygenHub crates/backdrop/src/scene.rs (6dc080f6) by
2 +
// scripts/sync-backdrop.sh: change it there, never here.
3 +
//! A backdrop's scene (docs/backgrounds.md §2): what it draws and how it
4 +
//! moves. The engine (lib.rs) drives any of them the same way.
5 +
6 +
use crate::backdrop::sky::{Colours, Kind};
7 +
8 +
pub trait Scene {
9 +
    /// The view is now `w` by `h` CSS pixels, the page's content column
10 +
    /// between `avoid`'s left and right.
11 +
    fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>);
12 +
    /// The cursor moved to `(x, y)`, or left the page.
13 +
    fn point(&mut self, at: Option<(f32, f32)>);
14 +
    /// Move on by `dt` seconds.
15 +
    fn step(&mut self, dt: f32);
16 +
    /// The marks, 8 floats each (sky::Sky::instances).
17 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>);
18 +
    /// The links, 12 floats each (sky::Sky::links).
19 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>);
20 +
    /// The name a resting cursor shows, and where.
21 +
    fn label(&self) -> Option<(&str, f32, f32)>;
22 +
    /// New activity in repository `repo` (§5, "Live"); only the sky shows it.
23 +
    fn arrive(&mut self, _repo: &str, _kind: Kind, _n: u32) {}
24 +
    /// Whether a frame would show anything new besides the drift, which
25 +
    /// the engine stops drawing a while after the last input (§6).
26 +
    fn busy(&self) -> bool;
27 +
    fn still(&self) -> bool;
28 +
    fn size(&self) -> (f32, f32);
29 +
}
30 +
31 +
impl Scene for crate::backdrop::sky::Sky {
32 +
    fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) {
33 +
        crate::backdrop::sky::Sky::resize(self, w, h, avoid)
34 +
    }
35 +
    fn point(&mut self, at: Option<(f32, f32)>) {
36 +
        crate::backdrop::sky::Sky::point(self, at)
37 +
    }
38 +
    fn step(&mut self, dt: f32) {
39 +
        crate::backdrop::sky::Sky::step(self, dt)
40 +
    }
41 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
42 +
        crate::backdrop::sky::Sky::instances(self, c, out)
43 +
    }
44 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>) {
45 +
        crate::backdrop::sky::Sky::links(self, c, out)
46 +
    }
47 +
    fn label(&self) -> Option<(&str, f32, f32)> {
48 +
        crate::backdrop::sky::Sky::label(self)
49 +
    }
50 +
    fn arrive(&mut self, repo: &str, kind: Kind, n: u32) {
51 +
        crate::backdrop::sky::Sky::arrive(self, repo, kind, n)
52 +
    }
53 +
    fn busy(&self) -> bool {
54 +
        crate::backdrop::sky::Sky::busy(self)
55 +
    }
56 +
    fn still(&self) -> bool {
57 +
        self.still
58 +
    }
59 +
    fn size(&self) -> (f32, f32) {
60 +
        (self.w, self.h)
61 +
    }
62 +
}
added rust-core/src/backdrop/sky.rs +754 −0
1 +
// Copied from OxygenHub crates/backdrop/src/sky.rs (6dc080f6) by
2 +
// scripts/sync-backdrop.sh: change it there, never here.
3 +
//! Your sky (docs/backgrounds.md §5): the dashboard's scene, its layout
4 +
//! and its motion, with no browser in it so it is tested natively.
5 +
//!
6 +
//! Each repository is a cluster placed by a hash of its name, so a
7 +
//! viewer's sky is the same on every visit; its events of the last 30 days
8 +
//! orbit it as marks, brightest when newest, shaped by kind. The cursor
9 +
//! lights marks near it without moving them, and joins the lit marks of
10 +
//! one repository in the order they happened.
11 +
12 +
use std::f32::consts::TAU;
13 +
14 +
/// The scale of marks and orbits: the sky drawn 40% closer than first
15 +
/// built (Andy, 2026-10-13).
16 +
const ZOOM: f32 = 1.4;
17 +
/// How far the cursor lights marks, in CSS pixels (§4).
18 +
pub const LIGHT_RADIUS: f32 = 170.0;
19 +
/// Seconds a mark takes to light, and to fade.
20 +
const LIT_IN: f32 = 0.15;
21 +
const LIT_OUT: f32 = 0.6;
22 +
/// The fastest a mark drifts, in CSS pixels a second.
23 +
pub const MAX_SPEED: f32 = 12.0;
24 +
/// How far each depth layer shifts against the cursor, front to back.
25 +
const PARALLAX: [f32; 3] = [6.0, 3.0, 1.0];
26 +
/// The longest link: only marks that happened one after another and sit
27 +
/// near each other are joined, so a busy repository reads as a
28 +
/// constellation, not a scribble.
29 +
const LINK_MAX: f32 = 85.0;
30 +
/// Links fade when the cursor has rested this long, in seconds.
31 +
const LINK_REST: f32 = 2.0;
32 +
/// A resting cursor this near a cluster's core names it, after this long.
33 +
const LABEL_NEAR: f32 = 110.0;
34 +
const LABEL_REST: f32 = 0.4;
35 +
/// Events older than this many days are not drawn.
36 +
pub const DAYS: f32 = 30.0;
37 +
/// The most marks one push makes (one a change), and in all.
38 +
const PER_PUSH: u32 = 40;
39 +
const MAX_MARKS: usize = 3000;
40 +
/// A side margin narrower than this holds no clusters: the sky falls back
41 +
/// to a ring around the page.
42 +
const MIN_MARGIN: f32 = 180.0;
43 +
/// The width a cluster gets in a margin's grid.
44 +
const CELL: f32 = 260.0;
45 +
/// An arrival's ring (§4): seconds it lasts, and its radius from first to
46 +
/// last, in CSS pixels before the zoom.
47 +
const RING_S: f32 = 1.2;
48 +
const RING_FROM: f32 = 6.0;
49 +
const RING_TO: f32 = 46.0;
50 +
51 +
/// What a mark stands for, which decides its shape.
52 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53 +
pub enum Kind {
54 +
    Push,
55 +
    Proposal,
56 +
    Issue,
57 +
    Release,
58 +
    /// A Flow run.
59 +
    Run,
60 +
    /// The repository itself, its cluster's core.
61 +
    Repo,
62 +
}
63 +
64 +
impl Kind {
65 +
    /// The code the page's data uses: 0 a push's change, 1 a proposal
66 +
    /// event, 2 an issue event, 3 a release, 4 a Flow run.
67 +
    pub fn from_code(code: u32) -> Kind {
68 +
        match code {
69 +
            1 => Kind::Proposal,
70 +
            2 => Kind::Issue,
71 +
            3 => Kind::Release,
72 +
            4 => Kind::Run,
73 +
            _ => Kind::Push,
74 +
        }
75 +
    }
76 +
77 +
    /// The shape the fragment shader draws: 0 dot, 1 diamond, 2 ring,
78 +
    /// 3 four-point star, 4 thin ring (a repository), 5 square.
79 +
    pub fn shape(self) -> f32 {
80 +
        match self {
81 +
            Kind::Push => 0.0,
82 +
            Kind::Proposal => 1.0,
83 +
            Kind::Issue => 2.0,
84 +
            Kind::Release => 3.0,
85 +
            Kind::Repo => 4.0,
86 +
            Kind::Run => 5.0,
87 +
        }
88 +
    }
89 +
90 +
    /// Its radius in CSS pixels.
91 +
    fn size(self) -> f32 {
92 +
        match self {
93 +
            Kind::Push => 2.0,
94 +
            Kind::Run => 2.2,
95 +
            Kind::Proposal => 3.2,
96 +
            Kind::Issue => 3.0,
97 +
            Kind::Release => 4.2,
98 +
            Kind::Repo => 6.0,
99 +
        }
100 +
    }
101 +
}
102 +
103 +
/// One repository, as the page gives it.
104 +
#[derive(Clone, Debug)]
105 +
pub struct Repo {
106 +
    pub name: String,
107 +
}
108 +
109 +
/// One event, as the page gives it: its repository (an index into the
110 +
/// repositories), its kind, how old it is, and how many marks it makes (a
111 +
/// push's changes; one otherwise).
112 +
#[derive(Clone, Copy, Debug)]
113 +
pub struct Event {
114 +
    pub repo: usize,
115 +
    pub kind: Kind,
116 +
    pub age_days: f32,
117 +
    pub n: u32,
118 +
}
119 +
120 +
/// A repository's place: a fraction of the view, and a depth layer.
121 +
#[derive(Clone, Debug)]
122 +
pub struct Cluster {
123 +
    pub name: String,
124 +
    pub fx: f32,
125 +
    pub fy: f32,
126 +
    pub layer: usize,
127 +
}
128 +
129 +
/// One drawn mark, orbiting its cluster.
130 +
#[derive(Clone, Debug)]
131 +
pub struct Mark {
132 +
    pub cluster: usize,
133 +
    pub kind: Kind,
134 +
    /// Its place in its cluster's history, 0 the newest; the core is 0
135 +
    /// too and has radius 0.
136 +
    pub order: u32,
137 +
    pub radius: f32,
138 +
    pub angle: f32,
139 +
    /// Radians a second, signed.
140 +
    pub speed: f32,
141 +
    /// 1 for today, fading to 0.3 for a month ago.
142 +
    pub bright: f32,
143 +
    /// How lit it is now, 0 to 1.
144 +
    pub lit: f32,
145 +
}
146 +
147 +
/// Straight RGBA, 0 to 1, as the page's tokens give them.
148 +
pub type Rgba = [f32; 4];
149 +
150 +
/// The three backdrop tokens (§3), and the page's status colours for the
151 +
/// scenes whose data has a status (Diffusion's pills).
152 +
#[derive(Clone, Copy, Debug, Default, PartialEq)]
153 +
pub struct Colours {
154 +
    pub mark: Rgba,
155 +
    pub lit: Rgba,
156 +
    pub link: Rgba,
157 +
    pub ok: Rgba,
158 +
    pub warn: Rgba,
159 +
    pub bad: Rgba,
160 +
}
161 +
162 +
/// A widening ring at a cluster's core: something just arrived there.
163 +
#[derive(Clone, Copy, Debug)]
164 +
pub struct Ring {
165 +
    pub cluster: usize,
166 +
    /// Seconds since it began.
167 +
    pub age: f32,
168 +
}
169 +
170 +
/// The scene.
171 +
pub struct Sky {
172 +
    pub clusters: Vec<Cluster>,
173 +
    pub marks: Vec<Mark>,
174 +
    /// The view, in CSS pixels.
175 +
    pub w: f32,
176 +
    pub h: f32,
177 +
    /// Where the cursor is, if it is over the page.
178 +
    pub cursor: Option<(f32, f32)>,
179 +
    /// Seconds since the cursor last moved.
180 +
    pub rested: f32,
181 +
    /// Still (reduced motion, or the viewer's choice): no drift, no
182 +
    /// parallax, and marks light at once.
183 +
    pub still: bool,
184 +
    /// The page's content column, left and right, in CSS pixels: clusters
185 +
    /// go in the margins beside it, where they can be seen.
186 +
    avoid: Option<(f32, f32)>,
187 +
    parallax: [(f32, f32); 3],
188 +
    link_fade: f32,
189 +
    /// Arrivals' rings, while they widen.
190 +
    pub rings: Vec<Ring>,
191 +
    /// Marks added since the page loaded, for their places in the lanes.
192 +
    arrived: u32,
193 +
}
194 +
195 +
/// FNV-1a, then splitmix64's finaliser: a small, stable hash, so a
196 +
/// repository's place never changes. The finaliser spreads every bit:
197 +
/// FNV alone gives names that differ only at the end (`repo#1`, `repo#2`)
198 +
/// alike high bits, which drew a cluster's marks as fans at a few angles.
199 +
pub fn hash(s: &str) -> u64 {
200 +
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
201 +
    for b in s.as_bytes() {
202 +
        h ^= *b as u64;
203 +
        h = h.wrapping_mul(0x0100_0000_01b3);
204 +
    }
205 +
    h = (h ^ (h >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
206 +
    h = (h ^ (h >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
207 +
    h ^ (h >> 31)
208 +
}
209 +
210 +
/// A number from 0 to 1 from bits of a hash.
211 +
fn unit(h: u64, shift: u32) -> f32 {
212 +
    ((h >> shift) & 0xffff) as f32 / 65536.0
213 +
}
214 +
215 +
impl Sky {
216 +
    /// The sky of `repos` and their `events` (newest first, as the page
217 +
    /// lists them), in a view of `w` by `h` CSS pixels.
218 +
    pub fn new(repos: &[Repo], events: &[Event], w: f32, h: f32, still: bool) -> Sky {
219 +
        // The most active repositories in front, the quiet ones behind.
220 +
        let mut activity = vec![0u32; repos.len()];
221 +
        for e in events {
222 +
            if e.repo < repos.len() && e.age_days <= DAYS {
223 +
                activity[e.repo] += 1;
224 +
            }
225 +
        }
226 +
        let mut rank: Vec<usize> = (0..repos.len()).collect();
227 +
        rank.sort_by_key(|&i| std::cmp::Reverse(activity[i]));
228 +
        let mut layer = vec![2usize; repos.len()];
229 +
        for (pos, &i) in rank.iter().enumerate() {
230 +
            layer[i] = (pos * 3 / repos.len().max(1)).min(2);
231 +
        }
232 +
        let clusters: Vec<Cluster> =
233 +
            repos.iter().enumerate().map(|(i, r)| Cluster { name: r.name.clone(), fx: 0.5, fy: 0.5, layer: layer[i] }).collect();
234 +
        let mut marks: Vec<Mark> = Vec::new();
235 +
        for i in 0..clusters.len() {
236 +
            marks.push(Mark { cluster: i, kind: Kind::Repo, order: 0, radius: 0.0, angle: 0.0, speed: 0.0, bright: 0.9, lit: 0.0 });
237 +
        }
238 +
        // Each event's marks: a push is a dot for each change it brought.
239 +
        let expanded: Vec<Event> = events
240 +
            .iter()
241 +
            .filter(|e| e.repo < repos.len() && e.age_days <= DAYS)
242 +
            .flat_map(|e| std::iter::repeat_n(*e, if e.kind == Kind::Push { e.n.clamp(1, PER_PUSH) as usize } else { 1 }))
243 +
            .take(MAX_MARKS)
244 +
            .collect();
245 +
        // A busy repository gets more lanes, so its marks spread out.
246 +
        let mut count = vec![0u32; repos.len()];
247 +
        for e in &expanded {
248 +
            count[e.repo] += 1;
249 +
        }
250 +
        let mut seen = vec![0u32; repos.len()];
251 +
        for e in &expanded {
252 +
            let k = seen[e.repo];
253 +
            seen[e.repo] += 1;
254 +
            let lanes = (6 + count[e.repo] / 30).min(14);
255 +
            let h = hash(&format!("{}#{k}", repos[e.repo].name));
256 +
            // Each repository turns one way; its marks keep their lanes, so
257 +
            // two clusters' streams cross without touching.
258 +
            let dir = if hash(&repos[e.repo].name) & 1 == 0 { 1.0 } else { -1.0 };
259 +
            // Lanes close together: each repository a tight knot.
260 +
            let radius = (16.0 + (k % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * ZOOM;
261 +
            let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
262 +
            marks.push(Mark {
263 +
                cluster: e.repo,
264 +
                kind: e.kind,
265 +
                order: k,
266 +
                radius,
267 +
                angle: unit(h, 32) * TAU,
268 +
                speed,
269 +
                bright: 1.0 - 0.7 * (e.age_days / DAYS).clamp(0.0, 1.0),
270 +
                lit: 0.0,
271 +
            });
272 +
        }
273 +
        let mut sky = Sky {
274 +
            clusters,
275 +
            marks,
276 +
            w,
277 +
            h,
278 +
            cursor: None,
279 +
            rested: 0.0,
280 +
            still,
281 +
            avoid: None,
282 +
            parallax: [(0.0, 0.0); 3],
283 +
            link_fade: 0.0,
284 +
            rings: Vec::new(),
285 +
            arrived: 0,
286 +
        };
287 +
        sky.layout();
288 +
        sky
289 +
    }
290 +
291 +
    /// The view is now `w` by `h`, its content column between `avoid`'s
292 +
    /// left and right.
293 +
    pub fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) {
294 +
        self.w = w;
295 +
        self.h = h;
296 +
        self.avoid = avoid;
297 +
        self.layout();
298 +
    }
299 +
300 +
    /// Place the clusters. In the margins beside the content when both are
301 +
    /// wide enough: repositories in the order of their names' hashes, split
302 +
    /// between the sides by their widths, each side a staggered grid from
303 +
    /// top to bottom, so the sky fills the space the page leaves. Otherwise
304 +
    /// a ring around the middle. The same repositories in the same view
305 +
    /// always land in the same places.
306 +
    fn layout(&mut self) {
307 +
        let n = self.clusters.len();
308 +
        let mut order: Vec<usize> = (0..n).collect();
309 +
        order.sort_by_key(|&i| hash(&self.clusters[i].name));
310 +
        let margins = self.avoid.and_then(|(x0, x1)| {
311 +
            let (l, r) = (x0.max(0.0), (self.w - x1).max(0.0));
312 +
            (l >= MIN_MARGIN && r >= MIN_MARGIN).then_some((l, r, x1))
313 +
        });
314 +
        // One repository's own sky (oxygenhub#196): its cluster sits in the
315 +
        // wider margin, a little below the middle, even a narrow one, so
316 +
        // it never lands on the page's heading or tabs.
317 +
        if n == 1 {
318 +
            if let Some((x0, x1)) = self.avoid {
319 +
                let (l, r) = (x0.max(0.0), (self.w - x1).max(0.0));
320 +
                if l.max(r) >= 40.0 && self.w > 0.0 {
321 +
                    let cx = if r >= l { x1 + r / 2.0 } else { l / 2.0 };
322 +
                    self.clusters[0].fx = cx / self.w;
323 +
                    self.clusters[0].fy = 0.6;
324 +
                    return;
325 +
                }
326 +
            }
327 +
        }
328 +
        match margins {
329 +
            Some((l, r, x1)) => {
330 +
                let left = (((n as f32) * l / (l + r)).round() as usize).min(n);
331 +
                let (a, b) = order.split_at(left);
332 +
                self.grid(a, 0.0, l);
333 +
                self.grid(b, x1, self.w);
334 +
            }
335 +
            None => {
336 +
                for c in &mut self.clusters {
337 +
                    let h = hash(&c.name);
338 +
                    let a = unit(h, 0) * TAU;
339 +
                    let d = 0.25 + 0.3 * unit(h, 16).sqrt();
340 +
                    c.fx = (0.5 + d * a.cos()).clamp(0.03, 0.97);
341 +
                    c.fy = (0.5 + 0.9 * d * a.sin()).clamp(0.05, 0.95);
342 +
                }
343 +
            }
344 +
        }
345 +
    }
346 +
347 +
    /// Lay `ids` out in a staggered grid between `x0` and `x1`.
348 +
    fn grid(&mut self, ids: &[usize], x0: f32, x1: f32) {
349 +
        if ids.is_empty() || self.w <= 0.0 || self.h <= 0.0 {
350 +
            return;
351 +
        }
352 +
        let (top, bottom) = (self.h * 0.1, self.h * 0.97);
353 +
        let width = x1 - x0;
354 +
        let cols = ((width / CELL) as usize).clamp(1, 3);
355 +
        let rows = ids.len().div_ceil(cols);
356 +
        let (cw, ch) = (width / cols as f32, (bottom - top) / rows as f32);
357 +
        for (j, &i) in ids.iter().enumerate() {
358 +
            let (col, row) = (j % cols, j / cols);
359 +
            let h = hash(&self.clusters[i].name);
360 +
            let stagger = if cols > 1 && row % 2 == 1 { cw * 0.25 } else { 0.0 };
361 +
            let cx = x0 + (col as f32 + 0.5) * cw + stagger + (unit(h, 0) - 0.5) * 0.3 * cw;
362 +
            let cy = top + (row as f32 + 0.5) * ch + (unit(h, 16) - 0.5) * 0.3 * ch;
363 +
            self.clusters[i].fx = cx.clamp(x0 + 24.0, x1 - 24.0) / self.w;
364 +
            self.clusters[i].fy = cy / self.h;
365 +
        }
366 +
    }
367 +
368 +
    /// The cursor moved to `(x, y)`, or left the page.
369 +
    pub fn point(&mut self, at: Option<(f32, f32)>) {
370 +
        self.cursor = at;
371 +
        self.rested = 0.0;
372 +
    }
373 +
374 +
    /// New activity in the repository `name` (§5, "Live"): its marks join
375 +
    /// its cluster as the newest, and one ring widens from its core. A
376 +
    /// repository the sky does not draw is ignored; a still sky adds the
377 +
    /// marks without the ring.
378 +
    pub fn arrive(&mut self, name: &str, kind: Kind, n: u32) {
379 +
        let Some(c) = self.clusters.iter().position(|cl| cl.name == name) else {
380 +
            return;
381 +
        };
382 +
        let add = if kind == Kind::Push { n.clamp(1, PER_PUSH) } else { 1 };
383 +
        for m in self.marks.iter_mut().filter(|m| m.cluster == c && m.kind != Kind::Repo) {
384 +
            m.order += add;
385 +
        }
386 +
        let have = self.marks.iter().filter(|m| m.cluster == c && m.kind != Kind::Repo).count() as u32;
387 +
        let lanes = (6 + (have + add) / 30).min(14);
388 +
        let dir = if hash(name) & 1 == 0 { 1.0 } else { -1.0 };
389 +
        for i in 0..add {
390 +
            let h = hash(&format!("{name}+{}", self.arrived));
391 +
            self.arrived += 1;
392 +
            let radius = (16.0 + ((have + i) % lanes) as f32 * 6.5 + unit(h, 0) * 4.0) * ZOOM;
393 +
            let speed = dir * (MAX_SPEED / radius).min(0.25) * (0.5 + 0.5 * unit(h, 16));
394 +
            self.marks.push(Mark { cluster: c, kind, order: add - 1 - i, radius, angle: unit(h, 32) * TAU, speed, bright: 1.0, lit: 0.0 });
395 +
        }
396 +
        // Over the limit, the cluster's oldest marks make room.
397 +
        while self.marks.len() > MAX_MARKS {
398 +
            let Some(oldest) = self
399 +
                .marks
400 +
                .iter()
401 +
                .enumerate()
402 +
                .filter(|(_, m)| m.cluster == c && m.kind != Kind::Repo)
403 +
                .max_by_key(|(_, m)| m.order)
404 +
                .map(|(i, _)| i)
405 +
            else {
406 +
                break;
407 +
            };
408 +
            self.marks.swap_remove(oldest);
409 +
        }
410 +
        if !self.still {
411 +
            self.rings.retain(|r| r.cluster != c);
412 +
            self.rings.push(Ring { cluster: c, age: 0.0 });
413 +
        }
414 +
    }
415 +
416 +
    /// Where cluster `c`'s core is now.
417 +
    pub fn core(&self, c: usize) -> (f32, f32) {
418 +
        let cl = &self.clusters[c];
419 +
        let (px, py) = self.parallax[cl.layer];
420 +
        (cl.fx * self.w + px, cl.fy * self.h + py)
421 +
    }
422 +
423 +
    /// Where mark `m` is now.
424 +
    pub fn position(&self, m: &Mark) -> (f32, f32) {
425 +
        let (cx, cy) = self.core(m.cluster);
426 +
        (cx + m.angle.cos() * m.radius, cy + m.angle.sin() * m.radius)
427 +
    }
428 +
429 +
    /// Move the scene on by `dt` seconds.
430 +
    pub fn step(&mut self, dt: f32) {
431 +
        let dt = dt.clamp(0.0, 0.1);
432 +
        self.rested += dt;
433 +
        for r in &mut self.rings {
434 +
            r.age += dt;
435 +
        }
436 +
        self.rings.retain(|r| r.age < RING_S);
437 +
        if !self.still {
438 +
            for m in &mut self.marks {
439 +
                m.angle = (m.angle + m.speed * dt) % TAU;
440 +
            }
441 +
            let ease = 1.0 - (-dt * 4.0).exp();
442 +
            for (l, p) in self.parallax.iter_mut().enumerate() {
443 +
                let (tx, ty) = match self.cursor {
444 +
                    Some((x, y)) if self.w > 0.0 && self.h > 0.0 => {
445 +
                        (-(x - self.w / 2.0) / (self.w / 2.0) * PARALLAX[l], -(y - self.h / 2.0) / (self.h / 2.0) * PARALLAX[l])
446 +
                    }
447 +
                    _ => (0.0, 0.0),
448 +
                };
449 +
                p.0 += (tx - p.0) * ease;
450 +
                p.1 += (ty - p.1) * ease;
451 +
            }
452 +
        }
453 +
        let lit_targets: Vec<f32> = self
454 +
            .marks
455 +
            .iter()
456 +
            .map(|m| match self.cursor {
457 +
                Some((x, y)) => {
458 +
                    let (mx, my) = self.position(m);
459 +
                    if (mx - x).hypot(my - y) <= LIGHT_RADIUS {
460 +
                        1.0
461 +
                    } else {
462 +
                        0.0
463 +
                    }
464 +
                }
465 +
                None => 0.0,
466 +
            })
467 +
            .collect();
468 +
        for (m, target) in self.marks.iter_mut().zip(lit_targets) {
469 +
            m.lit = if self.still {
470 +
                target
471 +
            } else if target > m.lit {
472 +
                (m.lit + dt / LIT_IN).min(target)
473 +
            } else {
474 +
                (m.lit - dt / LIT_OUT).max(target)
475 +
            };
476 +
        }
477 +
        let fade_to = if self.cursor.is_some() && self.rested < LINK_REST { 1.0 } else { 0.0 };
478 +
        self.link_fade = if self.still {
479 +
            fade_to
480 +
        } else if fade_to > self.link_fade {
481 +
            (self.link_fade + dt / LIT_IN).min(1.0)
482 +
        } else {
483 +
            (self.link_fade - dt / LIT_OUT).max(0.0)
484 +
        };
485 +
    }
486 +
487 +
    /// Whether anything besides the drift is still changing: lighting,
488 +
    /// links or a ring. The engine keeps drawing a moving sky for a while
489 +
    /// after the last input anyway (§6), then stops until something is.
490 +
    pub fn busy(&self) -> bool {
491 +
        !self.rings.is_empty() || self.marks.iter().any(|m| m.lit > 0.0 && m.lit < 1.0) || (self.link_fade > 0.0 && self.link_fade < 1.0)
492 +
    }
493 +
494 +
    /// One instance per mark: centre x, y and radius in CSS pixels, its
495 +
    /// shape, and its colour premultiplied, 8 floats each.
496 +
    pub fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
497 +
        out.clear();
498 +
        for m in &self.marks {
499 +
            let (x, y) = self.position(m);
500 +
            let rest = [c.mark[0], c.mark[1], c.mark[2], c.mark[3] * m.bright];
501 +
            let col = mix(rest, c.lit, m.lit);
502 +
            let size = m.kind.size() * ZOOM * (1.0 + 0.35 * m.lit);
503 +
            out.extend_from_slice(&[x, y, size, m.kind.shape(), col[0] * col[3], col[1] * col[3], col[2] * col[3], col[3]]);
504 +
        }
505 +
        // Arrivals: a thin ring in the lit colour, widening as it fades.
506 +
        for r in &self.rings {
507 +
            let t = (r.age / RING_S).clamp(0.0, 1.0);
508 +
            let (x, y) = self.core(r.cluster);
509 +
            let size = (RING_FROM + (RING_TO - RING_FROM) * (1.0 - (1.0 - t) * (1.0 - t))) * ZOOM;
510 +
            let a = c.lit[3] * (1.0 - t) * 0.8;
511 +
            out.extend_from_slice(&[x, y, size, Kind::Repo.shape(), c.lit[0] * a, c.lit[1] * a, c.lit[2] * a, a]);
512 +
        }
513 +
    }
514 +
515 +
    /// The links among lit marks: each repository's lit marks in the order
516 +
    /// they happened, its core to its newest lit mark. Two vertices a
517 +
    /// link, x, y and a premultiplied colour each, 6 floats a vertex.
518 +
    pub fn links(&self, c: &Colours, out: &mut Vec<f32>) {
519 +
        out.clear();
520 +
        if self.link_fade <= 0.0 {
521 +
            return;
522 +
        }
523 +
        for ci in 0..self.clusters.len() {
524 +
            let mut lit: Vec<&Mark> = self.marks.iter().filter(|m| m.cluster == ci && m.lit > 0.05).collect();
525 +
            lit.sort_by_key(|m| (m.kind != Kind::Repo, m.order));
526 +
            for pair in lit.windows(2) {
527 +
                let (a, b) = (self.position(pair[0]), self.position(pair[1]));
528 +
                if (a.0 - b.0).hypot(a.1 - b.1) > LINK_MAX {
529 +
                    continue;
530 +
                }
531 +
                let alpha = c.link[3] * pair[0].lit.min(pair[1].lit) * self.link_fade;
532 +
                let col = [c.link[0] * alpha, c.link[1] * alpha, c.link[2] * alpha, alpha];
533 +
                out.extend_from_slice(&[a.0, a.1, col[0], col[1], col[2], col[3], b.0, b.1, col[0], col[1], col[2], col[3]]);
534 +
            }
535 +
        }
536 +
    }
537 +
538 +
    /// How far a cluster's marks reach from its core.
539 +
    fn reach(&self, c: usize) -> f32 {
540 +
        self.marks.iter().filter(|m| m.cluster == c).map(|m| m.radius).fold(0.0, f32::max)
541 +
    }
542 +
543 +
    /// The name to show for a resting cursor, and where: centred just
544 +
    /// above the nearest cluster within reach, clear of its marks.
545 +
    pub fn label(&self) -> Option<(&str, f32, f32)> {
546 +
        let (x, y) = self.cursor?;
547 +
        if self.rested < LABEL_REST {
548 +
            return None;
549 +
        }
550 +
        (0..self.clusters.len())
551 +
            .map(|i| (i, self.core(i)))
552 +
            .map(|(i, (cx, cy))| (i, cx, cy, (cx - x).hypot(cy - y)))
553 +
            .filter(|t| t.3 <= LABEL_NEAR)
554 +
            .min_by(|a, b| a.3.total_cmp(&b.3))
555 +
            .map(|(i, cx, cy, _)| (self.clusters[i].name.as_str(), cx, cy - self.reach(i) - 10.0))
556 +
    }
557 +
}
558 +
559 +
fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba {
560 +
    let t = t.clamp(0.0, 1.0);
561 +
    [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t]
562 +
}
563 +
564 +
/// A computed CSS colour: `rgb(r, g, b)`, `rgba(r, g, b, a)` or
565 +
/// `color(srgb r g b / a)`, as browsers report them.
566 +
pub fn parse_colour(s: &str) -> Option<Rgba> {
567 +
    let s = s.trim();
568 +
    if let Some(inner) = s.strip_prefix("color(srgb").and_then(|r| r.strip_suffix(')')) {
569 +
        let (rgb, a) = match inner.split_once('/') {
570 +
            Some((rgb, a)) => (rgb, a.trim().parse().ok()?),
571 +
            None => (inner, 1.0),
572 +
        };
573 +
        let v: Vec<f32> = rgb.split_whitespace().map(|x| x.parse().ok()).collect::<Option<_>>()?;
574 +
        return (v.len() == 3).then(|| [v[0], v[1], v[2], a]);
575 +
    }
576 +
    let inner = s.strip_prefix("rgba(").or_else(|| s.strip_prefix("rgb("))?.strip_suffix(')')?;
577 +
    let v: Vec<f32> = inner.split([',', ' ', '/']).filter(|x| !x.is_empty()).map(|x| x.trim().parse().ok()).collect::<Option<_>>()?;
578 +
    match v.len() {
579 +
        3 => Some([v[0] / 255.0, v[1] / 255.0, v[2] / 255.0, 1.0]),
580 +
        4 => Some([v[0] / 255.0, v[1] / 255.0, v[2] / 255.0, v[3]]),
581 +
        _ => None,
582 +
    }
583 +
}
584 +
585 +
#[cfg(test)]
586 +
mod tests {
587 +
    use super::*;
588 +
589 +
    fn sky(still: bool) -> Sky {
590 +
        let repos = vec![Repo { name: "andy/hub".into() }, Repo { name: "andy/oxygen".into() }];
591 +
        let events = vec![
592 +
            Event { repo: 0, kind: Kind::Push, age_days: 0.0, n: 3 },
593 +
            Event { repo: 0, kind: Kind::Proposal, age_days: 2.0, n: 1 },
594 +
            Event { repo: 1, kind: Kind::Release, age_days: 29.0, n: 1 },
595 +
            Event { repo: 1, kind: Kind::Issue, age_days: 45.0, n: 1 },
596 +
        ];
597 +
        Sky::new(&repos, &events, 1600.0, 900.0, still)
598 +
    }
599 +
600 +
    #[test]
601 +
    fn a_sky_is_the_same_on_every_visit() {
602 +
        let (a, b) = (sky(false), sky(false));
603 +
        assert_eq!(a.clusters[0].fx, b.clusters[0].fx);
604 +
        assert_eq!(a.clusters[1].fy, b.clusters[1].fy);
605 +
        // A core per repository, a dot per change pushed, and one mark per
606 +
        // other event of the last 30 days.
607 +
        assert_eq!(a.marks.len(), 2 + 3 + 1 + 1);
608 +
        assert!(a.marks.iter().all(|m| m.kind != Kind::Issue), "older than 30 days: not drawn");
609 +
        let newest = a.marks.iter().find(|m| m.kind == Kind::Push).unwrap();
610 +
        let old = a.marks.iter().find(|m| m.kind == Kind::Release).unwrap();
611 +
        assert!(newest.bright > old.bright, "newer is brighter");
612 +
        assert_eq!(a.clusters[0].layer, 0, "the busiest in front");
613 +
    }
614 +
615 +
    #[test]
616 +
    fn marks_drift_slowly_and_the_cursor_lights_without_pulling() {
617 +
        let mut s = sky(false);
618 +
        for m in &s.marks {
619 +
            assert!(m.speed.abs() * m.radius <= MAX_SPEED + 0.01, "at most 12 px a second");
620 +
        }
621 +
        let target = s.position(&s.marks[2]);
622 +
        let radii: Vec<f32> = s.marks.iter().map(|m| m.radius).collect();
623 +
        s.point(Some(target));
624 +
        for _ in 0..30 {
625 +
            s.step(1.0 / 60.0);
626 +
        }
627 +
        assert!(s.marks[2].lit > 0.99, "lit near the cursor");
628 +
        assert_eq!(radii, s.marks.iter().map(|m| m.radius).collect::<Vec<_>>(), "nothing is pulled");
629 +
        let mut links = Vec::new();
630 +
        let colours = Colours { mark: [0.5, 0.5, 0.5, 0.2], lit: [0.0, 0.4, 0.9, 0.7], link: [0.0, 0.4, 0.9, 0.3], ..Default::default() };
631 +
        s.links(&colours, &mut links);
632 +
        assert!(!links.is_empty() && links.len() % 12 == 0, "lit marks of one repository are joined");
633 +
        // Resting fades the links; leaving fades the light.
634 +
        for _ in 0..240 {
635 +
            s.step(1.0 / 60.0);
636 +
        }
637 +
        s.links(&colours, &mut links);
638 +
        assert!(links.is_empty(), "a resting cursor's links fade");
639 +
        s.point(None);
640 +
        for _ in 0..60 {
641 +
            s.step(1.0 / 60.0);
642 +
        }
643 +
        assert!(s.marks.iter().all(|m| m.lit == 0.0));
644 +
    }
645 +
646 +
    #[test]
647 +
    fn still_means_no_drift_and_instant_light() {
648 +
        let mut s = sky(true);
649 +
        let before: Vec<f32> = s.marks.iter().map(|m| m.angle).collect();
650 +
        let at = s.position(&s.marks[2]);
651 +
        s.point(Some(at));
652 +
        s.step(1.0 / 60.0);
653 +
        assert_eq!(before, s.marks.iter().map(|m| m.angle).collect::<Vec<_>>());
654 +
        assert_eq!(s.marks[2].lit, 1.0);
655 +
        assert!(!s.busy(), "nothing left to animate");
656 +
    }
657 +
658 +
    #[test]
659 +
    fn instances_are_premultiplied_and_a_resting_cursor_names_a_cluster() {
660 +
        let mut s = sky(false);
661 +
        let colours = Colours { mark: [1.0, 1.0, 1.0, 0.5], lit: [0.0, 0.0, 1.0, 1.0], link: [0.0, 0.0, 1.0, 0.3], ..Default::default() };
662 +
        let mut inst = Vec::new();
663 +
        s.instances(&colours, &mut inst);
664 +
        assert_eq!(inst.len(), s.marks.len() * 8);
665 +
        let core = &inst[0..8];
666 +
        assert!((core[4] - core[7]).abs() < 1e-6 && core[7] < 0.5, "premultiplied, dimmed by brightness");
667 +
        let (cx, cy) = s.core(1);
668 +
        s.point(Some((cx + 10.0, cy)));
669 +
        assert!(s.label().is_none(), "not while moving");
670 +
        for _ in 0..10 {
671 +
            s.step(0.05);
672 +
        }
673 +
        assert_eq!(s.label().map(|l| l.0), Some("andy/oxygen"));
674 +
    }
675 +
676 +
    #[test]
677 +
    fn clusters_fill_the_margins_beside_the_content() {
678 +
        let repos: Vec<Repo> = (0..17).map(|i| Repo { name: format!("owner/repo{i}") }).collect();
679 +
        let mut s = Sky::new(&repos, &[], 2560.0, 1270.0, false);
680 +
        s.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
681 +
        let xs: Vec<f32> = (0..17).map(|i| s.core(i).0).collect();
682 +
        assert!(xs.iter().all(|&x| x < 707.0 || x > 1838.0), "none behind the content: {xs:?}");
683 +
        let left = xs.iter().filter(|&&x| x < 707.0).count();
684 +
        assert!((7..=10).contains(&left), "both sides used: {left} on the left");
685 +
        let ys: Vec<f32> = (0..17).map(|i| s.core(i).1).collect();
686 +
        assert!(ys.iter().any(|&y| y < 400.0) && ys.iter().any(|&y| y > 900.0), "top to bottom: {ys:?}");
687 +
        // The same view lays the same sky out.
688 +
        let mut again = Sky::new(&repos, &[], 2560.0, 1270.0, false);
689 +
        again.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
690 +
        assert_eq!(xs, (0..17).map(|i| again.core(i).0).collect::<Vec<_>>());
691 +
        // Narrow margins: back to the ring.
692 +
        s.resize(1200.0, 800.0, Some((100.0, 1100.0)));
693 +
        assert!((0..17).any(|i| (s.core(i).0 - 600.0).abs() < 400.0));
694 +
    }
695 +
696 +
    #[test]
697 +
    fn a_clusters_marks_spread_all_the_way_round() {
698 +
        let repos = vec![Repo { name: "doomsdayone/rstar-api".into() }];
699 +
        let events: Vec<Event> = (0..200).map(|_| Event { repo: 0, kind: Kind::Issue, age_days: 1.0, n: 1 }).collect();
700 +
        let s = Sky::new(&repos, &events, 1600.0, 900.0, true);
701 +
        // Every eighth of the circle holds a fair share of the marks.
702 +
        let mut octants = [0u32; 8];
703 +
        for m in s.marks.iter().filter(|m| m.kind == Kind::Issue) {
704 +
            octants[((m.angle / TAU * 8.0) as usize).min(7)] += 1;
705 +
        }
706 +
        assert!(octants.iter().all(|&n| (12..=40).contains(&n)), "{octants:?}");
707 +
    }
708 +
709 +
    #[test]
710 +
    fn one_repository_sits_in_the_wider_margin() {
711 +
        let repos = vec![Repo { name: "andy/demo".into() }];
712 +
        let mut s = Sky::new(&repos, &[], 1400.0, 900.0, false);
713 +
        s.resize(1400.0, 900.0, Some((127.0, 1259.0)));
714 +
        let (x, y) = s.core(0);
715 +
        assert!((x - (1259.0 + 141.0 / 2.0)).abs() < 8.0 && (y - 540.0).abs() < 8.0, "{x} {y}");
716 +
    }
717 +
718 +
    #[test]
719 +
    fn an_arrival_joins_its_cluster_with_one_ring() {
720 +
        let mut s = sky(false);
721 +
        let before = s.marks.iter().filter(|m| m.cluster == 1).count();
722 +
        s.arrive("andy/oxygen", Kind::Push, 2);
723 +
        s.arrive("someone/else", Kind::Issue, 1);
724 +
        let marks: Vec<&Mark> = s.marks.iter().filter(|m| m.cluster == 1).collect();
725 +
        assert_eq!(marks.len(), before + 2, "a dot for each change pushed; an unknown repository adds nothing");
726 +
        let newest: Vec<&&Mark> = marks.iter().filter(|m| m.kind == Kind::Push).collect();
727 +
        assert!(newest.iter().all(|m| m.order < 2 && m.bright == 1.0), "the newest, at full brightness");
728 +
        assert!(marks.iter().any(|m| m.kind == Kind::Release && m.order == 2), "older marks move back");
729 +
        assert_eq!(s.rings.len(), 1);
730 +
        assert!(s.busy(), "a ring is drawn while it widens");
731 +
        let colours = Colours { lit: [0.0, 0.4, 0.9, 0.7], ..Default::default() };
732 +
        let mut inst = Vec::new();
733 +
        s.instances(&colours, &mut inst);
734 +
        assert_eq!(inst.len(), (s.marks.len() + 1) * 8, "the ring is one more instance");
735 +
        for _ in 0..80 {
736 +
            s.step(1.0 / 60.0);
737 +
        }
738 +
        assert!(s.rings.is_empty() && !s.busy(), "gone after 1.2 s, and nothing left to draw but the drift");
739 +
        // A still sky takes the marks without the ring.
740 +
        let mut still = sky(true);
741 +
        still.arrive("andy/hub", Kind::Release, 1);
742 +
        assert!(still.rings.is_empty());
743 +
        assert_eq!(still.marks.iter().filter(|m| m.kind == Kind::Release).count(), 2);
744 +
    }
745 +
746 +
    #[test]
747 +
    fn computed_colours_parse() {
748 +
        assert_eq!(parse_colour("rgb(9, 105, 218)"), Some([9.0 / 255.0, 105.0 / 255.0, 218.0 / 255.0, 1.0]));
749 +
        assert_eq!(parse_colour("rgba(0, 240, 255, 0.75)").map(|c| c[3]), Some(0.75));
750 +
        assert_eq!(parse_colour("color(srgb 0 0.5 1 / 0.2)"), Some([0.0, 0.5, 1.0, 0.2]));
751 +
        assert_eq!(parse_colour("rgb(1 2 3 / 0.5)").map(|c| c[3]), Some(0.5));
752 +
        assert!(parse_colour("transparent").is_none());
753 +
    }
754 +
}
added rust-core/src/backdrop/swarm.rs +206 −0
1 +
// Copied from OxygenHub crates/backdrop/src/swarm.rs (6dc080f6) by
2 +
// scripts/sync-backdrop.sh: change it there, never here.
3 +
//! The swarm (docs/backgrounds.md §2): Admin › Runners › Images. The hub
4 +
//! and the runners as nodes, each ringed by a dot for every image it
5 +
//! holds; while a runner fetches an image, chunks (small squares) stream
6 +
//! to it from every holder, the hub and its peers alike, as Jetstream's
7 +
//! swarm fetches them. The data is the page's own snapshot.
8 +
9 +
use crate::backdrop::field::{knot, margins, Dot, Field, Named, Orbit, ZOOM};
10 +
use crate::backdrop::scene::Scene;
11 +
use crate::backdrop::sky::Colours;
12 +
use std::f32::consts::PI;
13 +
14 +
/// Dots for each image a node holds (its chunks, drawn as a sky's knot).
15 +
const PER_IMAGE: usize = 6;
16 +
/// Chunks streaming along each holder-to-fetcher path, their speed, and
17 +
/// the most streams drawn.
18 +
const CHUNKS: usize = 20;
19 +
const CHUNK_SPEED: f32 = 40.0;
20 +
const MAX_STREAMS: usize = 40;
21 +
22 +
/// One image: the nodes that hold it and those fetching it now.
23 +
pub struct Image {
24 +
    pub held: Vec<usize>,
25 +
    pub fetching: Vec<usize>,
26 +
}
27 +
28 +
struct Held {
29 +
    node: usize,
30 +
    orbit: Orbit,
31 +
}
32 +
33 +
pub struct Swarm {
34 +
    field: Field,
35 +
    /// The hub first, then the runners.
36 +
    names: Vec<String>,
37 +
    pos: Vec<(f32, f32)>,
38 +
    held: Vec<Held>,
39 +
    /// Streams: from, to, and their chunks' first index.
40 +
    streams: Vec<(usize, usize, usize)>,
41 +
    w: f32,
42 +
    h: f32,
43 +
    t: f32,
44 +
}
45 +
46 +
impl Swarm {
47 +
    pub fn new(names: Vec<String>, images: Vec<Image>, still: bool) -> Swarm {
48 +
        let mut field = Field::new(still);
49 +
        for i in 0..names.len() {
50 +
            field.dots.push(Dot::new(0.0, 0.0, if i == 0 { 10.0 } else { 8.0 }, 4.0));
51 +
        }
52 +
        let mut held = Vec::new();
53 +
        let mut count = vec![0usize; names.len()];
54 +
        for img in &images {
55 +
            for &node in img.held.iter().filter(|&&n| n < names.len()) {
56 +
                count[node] += 1;
57 +
            }
58 +
        }
59 +
        for (node, name) in names.iter().enumerate() {
60 +
            for (k, orbit) in knot(name, count[node] * PER_IMAGE).into_iter().enumerate() {
61 +
                let mut d = Dot::new(0.0, 0.0, 2.0 * ZOOM, if k % PER_IMAGE == 0 { 5.0 } else { 0.0 });
62 +
                d.group = node as u32;
63 +
                d.order = k as u32;
64 +
                d.bright = 0.75;
65 +
                field.dots.push(d);
66 +
                held.push(Held { node, orbit });
67 +
            }
68 +
        }
69 +
        let mut streams = Vec::new();
70 +
        'images: for img in &images {
71 +
            for &to in img.fetching.iter().filter(|&&n| n < names.len()) {
72 +
                for &from in img.held.iter().filter(|&&n| n < names.len() && n != to) {
73 +
                    if streams.len() == MAX_STREAMS {
74 +
                        break 'images;
75 +
                    }
76 +
                    streams.push((from, to, field.dots.len()));
77 +
                    for _ in 0..CHUNKS {
78 +
                        let mut d = Dot::new(0.0, 0.0, 1.8 * ZOOM, 5.0);
79 +
                        d.bright = 0.0;
80 +
                        field.dots.push(d);
81 +
                    }
82 +
                }
83 +
            }
84 +
        }
85 +
        let mut s = Swarm { field, names, pos: Vec::new(), held, streams, w: 0.0, h: 0.0, t: 0.0 };
86 +
        s.layout(None);
87 +
        s
88 +
    }
89 +
90 +
    fn layout(&mut self, avoid: Option<(f32, f32)>) {
91 +
        let (w, h) = (self.w, self.h);
92 +
        let runners = self.names.len().saturating_sub(1);
93 +
        let (hub_x, run_x) = match margins(w, avoid) {
94 +
            Some((l, x1)) => (l * 0.5, x1 + (w - x1) * 0.5),
95 +
            None => (w * 0.1, w * 0.9),
96 +
        };
97 +
        self.pos = (0..self.names.len())
98 +
            .map(|i| match i {
99 +
                0 => (hub_x, h * 0.5),
100 +
                _ if runners <= 1 => (run_x, h * 0.5),
101 +
                i => (run_x, h * 0.2 + h * 0.6 * (i - 1) as f32 / (runners - 1) as f32),
102 +
            })
103 +
            .collect();
104 +
        self.field.named = self
105 +
            .names
106 +
            .iter()
107 +
            .zip(&self.pos)
108 +
            .enumerate()
109 +
            .map(|(i, (name, &(x, y)))| {
110 +
                let reach = self.held.iter().filter(|h| h.node == i).map(|h| h.orbit.radius).fold(12.0, f32::max);
111 +
                Named { name: name.clone(), x, y, reach }
112 +
            })
113 +
            .collect();
114 +
    }
115 +
116 +
    fn place(&mut self) {
117 +
        let n = self.names.len();
118 +
        for (i, &(x, y)) in self.pos.iter().enumerate() {
119 +
            self.field.dots[i].x = x;
120 +
            self.field.dots[i].y = y;
121 +
        }
122 +
        for (k, hd) in self.held.iter().enumerate() {
123 +
            let (x, y) = hd.orbit.at(self.pos[hd.node], self.t);
124 +
            self.field.dots[n + k].x = x;
125 +
            self.field.dots[n + k].y = y;
126 +
        }
127 +
        for &(from, to, first) in &self.streams {
128 +
            let (a, b) = (self.pos[from], self.pos[to]);
129 +
            let len = (b.0 - a.0).hypot(b.1 - a.1).max(1.0);
130 +
            // Peers' streams bow one way, the hub's the other, so they
131 +
            // read as separate sources.
132 +
            let bow = if from == 0 { -0.12 } else { 0.12 };
133 +
            let ctrl = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0 + len * bow);
134 +
            for k in 0..CHUNKS {
135 +
                let s = (k as f32 / CHUNKS as f32 + self.t * CHUNK_SPEED / len).fract();
136 +
                let u = 1.0 - s;
137 +
                let d = &mut self.field.dots[first + k];
138 +
                d.x = u * u * a.0 + 2.0 * u * s * ctrl.0 + s * s * b.0;
139 +
                d.y = u * u * a.1 + 2.0 * u * s * ctrl.1 + s * s * b.1;
140 +
                d.bright = (s * PI).sin();
141 +
            }
142 +
        }
143 +
    }
144 +
}
145 +
146 +
impl Scene for Swarm {
147 +
    fn resize(&mut self, w: f32, h: f32, avoid: Option<(f32, f32)>) {
148 +
        self.w = w;
149 +
        self.h = h;
150 +
        self.layout(avoid);
151 +
        self.place();
152 +
    }
153 +
    fn point(&mut self, at: Option<(f32, f32)>) {
154 +
        self.field.point(at);
155 +
    }
156 +
    fn step(&mut self, dt: f32) {
157 +
        if !self.field.still {
158 +
            self.t += dt.clamp(0.0, 0.1);
159 +
        }
160 +
        self.place();
161 +
        self.field.light(dt);
162 +
    }
163 +
    fn instances(&self, c: &Colours, out: &mut Vec<f32>) {
164 +
        self.field.instances(c, out)
165 +
    }
166 +
    fn links(&self, c: &Colours, out: &mut Vec<f32>) {
167 +
        self.field.links(c, out)
168 +
    }
169 +
    fn label(&self) -> Option<(&str, f32, f32)> {
170 +
        self.field.label()
171 +
    }
172 +
    fn busy(&self) -> bool {
173 +
        self.field.easing()
174 +
    }
175 +
    fn still(&self) -> bool {
176 +
        self.field.still
177 +
    }
178 +
    fn size(&self) -> (f32, f32) {
179 +
        (self.w, self.h)
180 +
    }
181 +
}
182 +
183 +
#[cfg(test)]
184 +
mod tests {
185 +
    use super::*;
186 +
187 +
    #[test]
188 +
    fn chunks_stream_to_a_fetching_runner_from_every_holder() {
189 +
        let names = vec!["hub".to_string(), "earth-1".to_string(), "plex-1".to_string()];
190 +
        let images = vec![Image { held: vec![0, 1], fetching: vec![2] }, Image { held: vec![0, 1, 2], fetching: vec![] }];
191 +
        let mut s = Swarm::new(names, images, false);
192 +
        s.resize(2560.0, 1270.0, Some((707.0, 1838.0)));
193 +
        assert!(s.pos[0].0 < 707.0 && s.pos[1].0 > 1838.0 && s.pos[2].0 > 1838.0, "the hub on one side, runners on the other");
194 +
        assert_eq!(s.held.len(), 5 * PER_IMAGE, "dots for every image each node holds");
195 +
        assert_eq!(s.streams.iter().map(|&(a, b, _)| (a, b)).collect::<Vec<_>>(), vec![(0, 2), (1, 2)], "from the hub and the peer");
196 +
        let first = s.streams[0].2;
197 +
        let before = s.field.dots[first].x;
198 +
        s.step(0.1);
199 +
        assert_ne!(before, s.field.dots[first].x);
200 +
        s.point(Some(s.pos[1]));
201 +
        for _ in 0..30 {
202 +
            s.step(0.02);
203 +
        }
204 +
        assert_eq!(s.label().map(|l| l.0), Some("earth-1"));
205 +
    }
206 +
}
added rust-core/src/scenes.rs +168 −0
1 +
//! The website's living backgrounds on the phone (E1): the hub's own
2 +
//! scenes (backdrop/, copied by scripts/sync-backdrop.sh) stepped here, and
3 +
//! drawn by the app from the floats each frame gives (ui/backdrop), as the
4 +
//! hub's WebGL draws them in a browser.
5 +
6 +
use crate::backdrop::adrift::Adrift;
7 +
use crate::backdrop::mark::Mark;
8 +
use crate::backdrop::scene::Scene;
9 +
use crate::backdrop::sky::{Colours, Event, Kind, Repo, Sky};
10 +
use std::sync::Mutex;
11 +
12 +
/// One event of the viewer's own activity, for Your sky: an index into the
13 +
/// repositories, its kind (0 a push's change, 1 a proposal event, 2 an
14 +
/// issue event, 3 a release, 4 a Flow run), how old it is and how many
15 +
/// marks it makes.
16 +
#[derive(uniffi::Record)]
17 +
pub struct SkyEvent {
18 +
    pub repo: u32,
19 +
    pub kind: u32,
20 +
    pub age_days: f32,
21 +
    pub n: u32,
22 +
}
23 +
24 +
/// A frame to draw: marks, 8 floats each (x, y, size, shape, then a
25 +
/// premultiplied colour), and links, 12 floats each (two ends, each x, y
26 +
/// and a premultiplied colour), as little-endian bytes: a byte array
27 +
/// crosses to Kotlin as one copy, where a list of floats would be boxed.
28 +
#[derive(uniffi::Record)]
29 +
pub struct BackdropFrame {
30 +
    pub marks: Vec<u8>,
31 +
    pub links: Vec<u8>,
32 +
}
33 +
34 +
fn bytes(v: &[f32]) -> Vec<u8> {
35 +
    v.iter().flat_map(|f| f.to_le_bytes()).collect()
36 +
}
37 +
38 +
/// The name a resting finger shows, and where.
39 +
#[derive(uniffi::Record)]
40 +
pub struct BackdropLabel {
41 +
    pub text: String,
42 +
    pub x: f32,
43 +
    pub y: f32,
44 +
}
45 +
46 +
/// A scene and the colours it is drawn in.
47 +
#[derive(uniffi::Object)]
48 +
pub struct Backdrop {
49 +
    scene: Mutex<Box<dyn Scene + Send>>,
50 +
    colours: Mutex<Colours>,
51 +
}
52 +
53 +
fn rgba(c: &[f32], i: usize) -> [f32; 4] {
54 +
    [c[i * 4], c[i * 4 + 1], c[i * 4 + 2], c[i * 4 + 3]]
55 +
}
56 +
57 +
impl Backdrop {
58 +
    fn of(scene: Box<dyn Scene + Send>) -> std::sync::Arc<Backdrop> {
59 +
        let grey = [0.5, 0.55, 0.65, 0.5];
60 +
        let colours = Colours { mark: grey, lit: grey, link: grey, ok: grey, warn: grey, bad: grey };
61 +
        std::sync::Arc::new(Backdrop { scene: Mutex::new(scene), colours: Mutex::new(colours) })
62 +
    }
63 +
}
64 +
65 +
#[uniffi::export]
66 +
impl Backdrop {
67 +
    /// The OxygenHub mark, as behind the hub's sign-in page.
68 +
    #[uniffi::constructor]
69 +
    pub fn mark(still: bool) -> std::sync::Arc<Backdrop> {
70 +
        Backdrop::of(Box::new(Mark::new(still)))
71 +
    }
72 +
73 +
    /// The quiet field behind the hub's error pages, for an empty screen.
74 +
    #[uniffi::constructor]
75 +
    pub fn adrift(still: bool) -> std::sync::Arc<Backdrop> {
76 +
        Backdrop::of(Box::new(Adrift::new(still)))
77 +
    }
78 +
79 +
    /// Your sky, as on the hub's dashboard: the viewer's own repositories
80 +
    /// and their activity, nothing inferred.
81 +
    #[uniffi::constructor]
82 +
    pub fn sky(repos: Vec<String>, events: Vec<SkyEvent>, w: f32, h: f32, still: bool) -> std::sync::Arc<Backdrop> {
83 +
        let repos: Vec<Repo> = repos.into_iter().map(|name| Repo { name }).collect();
84 +
        let events: Vec<Event> = events
85 +
            .into_iter()
86 +
            .filter(|e| (e.repo as usize) < repos.len())
87 +
            .map(|e| Event { repo: e.repo as usize, kind: Kind::from_code(e.kind), age_days: e.age_days, n: e.n.max(1) })
88 +
            .collect();
89 +
        Backdrop::of(Box::new(Sky::new(&repos, &events, w, h, still)))
90 +
    }
91 +
92 +
    /// The colours to draw in, from the app's theme: mark, lit, link, ok,
93 +
    /// warn and bad, 4 floats each (red, green, blue, alpha, 0 to 1).
94 +
    pub fn colours(&self, rgba_x6: Vec<f32>) {
95 +
        if rgba_x6.len() < 24 {
96 +
            return;
97 +
        }
98 +
        let c = &rgba_x6;
99 +
        *self.colours.lock().unwrap() =
100 +
            Colours { mark: rgba(c, 0), lit: rgba(c, 1), link: rgba(c, 2), ok: rgba(c, 3), warn: rgba(c, 4), bad: rgba(c, 5) };
101 +
    }
102 +
103 +
    /// The view is now `w` by `h` (density-independent pixels).
104 +
    pub fn resize(&self, w: f32, h: f32) {
105 +
        self.scene.lock().unwrap().resize(w, h, None);
106 +
    }
107 +
108 +
    /// A finger at `(x, y)`, or lifted.
109 +
    pub fn point(&self, x: f32, y: f32, down: bool) {
110 +
        self.scene.lock().unwrap().point(down.then_some((x, y)));
111 +
    }
112 +
113 +
    /// Move on by `dt` seconds; whether anything but the drift is moving.
114 +
    pub fn step(&self, dt: f32) -> bool {
115 +
        let mut s = self.scene.lock().unwrap();
116 +
        s.step(dt.clamp(0.0, 0.1));
117 +
        s.busy()
118 +
    }
119 +
120 +
    /// What to draw now.
121 +
    pub fn frame(&self) -> BackdropFrame {
122 +
        let (s, c) = (self.scene.lock().unwrap(), self.colours.lock().unwrap());
123 +
        let (mut marks, mut links) = (Vec::new(), Vec::new());
124 +
        s.instances(&c, &mut marks);
125 +
        s.links(&c, &mut links);
126 +
        BackdropFrame { marks: bytes(&marks), links: bytes(&links) }
127 +
    }
128 +
129 +
    /// The name a resting finger shows, if any.
130 +
    pub fn label(&self) -> Option<BackdropLabel> {
131 +
        self.scene.lock().unwrap().label().map(|(t, x, y)| BackdropLabel { text: t.to_string(), x, y })
132 +
    }
133 +
}
134 +
135 +
#[cfg(test)]
136 +
mod tests {
137 +
    use super::*;
138 +
139 +
    #[test]
140 +
    fn a_sky_of_real_repositories_draws_marks_and_moves() {
141 +
        let b = Backdrop::sky(
142 +
            vec!["andy/oxygen".into(), "andy/hub".into()],
143 +
            vec![SkyEvent { repo: 0, kind: 0, age_days: 1.0, n: 5 }, SkyEvent { repo: 1, kind: 4, age_days: 2.0, n: 1 }, SkyEvent { repo: 9, kind: 0, age_days: 1.0, n: 1 }],
144 +
            400.0,
145 +
            800.0,
146 +
            false,
147 +
        );
148 +
        b.colours(vec![0.6; 24]);
149 +
        b.step(0.016);
150 +
        let f = b.frame();
151 +
        assert!(!f.marks.is_empty() && f.marks.len() % 32 == 0, "{} bytes", f.marks.len());
152 +
        assert_eq!(f.links.len() % 48, 0);
153 +
        let first = f.marks.clone();
154 +
        for _ in 0..30 {
155 +
            b.step(0.033);
156 +
        }
157 +
        assert_ne!(b.frame().marks, first, "it drifts");
158 +
    }
159 +
160 +
    #[test]
161 +
    fn the_mark_and_the_field_draw_too() {
162 +
        for b in [Backdrop::mark(false), Backdrop::adrift(true)] {
163 +
            b.resize(360.0, 640.0);
164 +
            b.step(0.016);
165 +
            assert!(!b.frame().marks.is_empty());
166 +
        }
167 +
    }
168 +
}
added rust-core/uniffi.toml +5 −0
1 +
# Kotlin bindings for Android: objects (the backdrop scenes, #23) are freed
2 +
# by Android's SystemCleaner from API 34 and JNA's cleaner before it, where
3 +
# the JVM default calls java.lang.ref.Cleaner, which API 31 and 32 lack.
4 +
[bindings.kotlin]
5 +
android = true
added scripts/sync-backdrop.sh +38 −0
1 +
#!/bin/sh
2 +
# Copy the hub's backdrop scenes into rust-core (E1 of the app's polish):
3 +
# the pure Rust that moves the website's living backgrounds (OxygenHub
4 +
# crates/backdrop: field, sky, mark, adrift, ripples, swarm, scene), with
5 +
# none of its browser glue, so the phone draws the very same scenes. The hub
6 +
# is their one source: never edit rust-core/src/backdrop by hand; change
7 +
# them in the hub and run this again.
8 +
#
9 +
#   scripts/sync-backdrop.sh [HUB_CHECKOUT]   (default ../oxygenhub)
10 +
set -eu
11 +
cd "$(dirname "$0")/.."
12 +
hub=${1:-../oxygenhub}
13 +
src="$hub/crates/backdrop/src"
14 +
[ -f "$src/sky.rs" ] || { echo "sync-backdrop: no $src/sky.rs (give the hub's checkout)" >&2; exit 1; }
15 +
from=$(cd "$hub" && oxy --plain log -n 1 -r "$(oxy --plain status | sed -n 's/^On \([^ ]*\) .*/\1/p')" 2>/dev/null | sed -n '1s/^[^0-9a-f]*\([0-9a-f]\{8\}\).*/\1/p')
16 +
out=rust-core/src/backdrop
17 +
mkdir -p "$out"
18 +
for f in field sky mark adrift ripples swarm scene; do
19 +
    {
20 +
        echo "// Copied from OxygenHub crates/backdrop/src/$f.rs (${from:-its main}) by"
21 +
        echo "// scripts/sync-backdrop.sh: change it there, never here."
22 +
        sed 's/crate::\(field\|sky\|scene\|mark\|adrift\|ripples\|swarm\)/crate::backdrop::\1/g' "$src/$f.rs"
23 +
    } > "$out/$f.rs"
24 +
done
25 +
cat > "$out/mod.rs" <<'MOD'
26 +
//! The hub's backdrop scenes (OxygenHub crates/backdrop), copied by
27 +
//! scripts/sync-backdrop.sh: the website's living backgrounds, stepped here
28 +
//! and drawn by the app (ui/backdrop).
29 +
#![allow(dead_code)]
30 +
pub mod adrift;
31 +
pub mod field;
32 +
pub mod mark;
33 +
pub mod ripples;
34 +
pub mod scene;
35 +
pub mod sky;
36 +
pub mod swarm;
37 +
MOD
38 +
echo "sync-backdrop: copied 7 scenes from $hub (${from:-?})"
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **The hub's living backgrounds (#23):** the website's backdrops come to the phone, the same Rust scenes (copied from OxygenHub's crates/backdrop by scripts/sync-backdrop.sh) stepped in rust-core and drawn by the app. Sign-in has the OxygenHub mark, which answers a finger; the dashboard has Your sky, your repositories and their last 30 days of pushes, proposals, issues, releases and runs, each mark a real event (OxygenHub's `GET /user/sky`; a hub without it shows the quiet field), drawing near your finger and naming the repository it rests by, behind cards it shows through. Live runs and jobs pulse. As on the web, drawing slows 3 s after the last touch and stops after 2 minutes, and it holds still with the system's animations off or battery saver on.
5 6
- **Assign issues from the phone (#4):** an issue's Assignees action shows who it is assigned to, each removable, with Assign me and a field for anyone's username; saved as the whole set (triage access on the hub).
6 7
- **Approve, reject and expedite runs (#5):** a run's job that waits for a person shows Approve and Reject (its approval rules on the hub decide who may), and a live run has Expedite, which asks why it is urgent (a maintainer's, within the owner's cap). Needs OxygenHub 0.9.11 (the job's `approval` and the expedite route).
7 8
- **Review a proposal from the phone (#4):** an open proposal's Reviews section takes a review: Approve, Request changes or Comment, with what you think (needed to ask for changes or to comment), for a phone signed in to change things. An approval from the phone is not sealed, as it holds no key of yours: where a repository needs sealed approvals it does not count yet.
…
22 unchanged lines
modified app/build.gradle.kts +1 −0
…
40 unchanged lines
41 41
    dependsOn(buildRustCore)
42 42
    workingDir = rustCore
43 43
    inputs.dir(rustJniLibs)
44 +
    inputs.file(rustCore.resolve("uniffi.toml"))
44 45
    outputs.dir(rustBindings)
45 46
    commandLine(
46 47
        "cargo", "run", "--release", "--bin", "uniffi-bindgen", "generate",
…
143 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +5 −0
…
53 unchanged lines
54 54
     */
55 55
    suspend fun userProposals(landable: Boolean = false): UserProposals
56 56
57 +
    /** Your sky's data, the dashboard backdrop's (#23); a hub before it answers 404. */
58 +
    suspend fun userSky(): UserSky
59 +
57 60
    /** A repository's proposals: open (with drafts), landed, closed or all. */
58 61
    suspend fun proposals(owner: String, name: String, state: ProposalState = ProposalState.Open): List<Proposal>
59 62
…
246 unchanged lines
306 309
        val tags = get(url("repos", owner, name, "tags"), ListSerializer(NamedRef.serializer())).map { it.name }
307 310
        return bookmarks to tags
308 311
    }
312 +
313 +
    override suspend fun userSky(): UserSky = get(url("user", "sky"), UserSky.serializer())
309 314
310 315
    override suspend fun userProposals(landable: Boolean): UserProposals =
311 316
        get(url("user", "proposals") { if (landable) addQueryParameter("landable", "true") }, UserProposals.serializer())
…
126 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +14 −0
…
224 unchanged lines
225 225
    val landable: List<ProposalBrief> = emptyList(),
226 226
)
227 227
228 +
/**
229 +
 * `GET /api/v1/user/sky` (#23): what the hub's dashboard backdrop draws, the
230 +
 * caller's repositories and their events of the last 30 days.
231 +
 */
232 +
@Serializable
233 +
data class UserSky(
234 +
    /** The hub's clock (ms), to age the events by. */
235 +
    val now: Long = 0,
236 +
    /** `owner/name` of each. */
237 +
    val repos: List<String> = emptyList(),
238 +
    /** `[repo, kind, at_ms, n]`: an index into [repos]; kind 0 a push of n changes, 1 a proposal event, 2 an issue event, 3 a release, 4 a Flow run. */
239 +
    val events: List<List<Long>> = emptyList(),
240 +
)
241 +
228 242
/** An organisation (`GET /orgs/{name}`). */
229 243
@Serializable
230 244
data class Organisation(
…
236 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardScreen.kt +15 −4
…
11 unchanged lines
12 12
import androidx.compose.foundation.layout.Column
13 13
import androidx.compose.foundation.layout.Row
14 14
import androidx.compose.foundation.layout.Spacer
15 +
import androidx.compose.foundation.layout.fillMaxSize
15 16
import androidx.compose.foundation.layout.fillMaxWidth
16 17
import androidx.compose.foundation.layout.height
17 18
import androidx.compose.foundation.layout.padding
…
3 unchanged lines
21 22
import androidx.compose.material.icons.outlined.Inbox
22 23
import androidx.compose.material.icons.outlined.MarkEmailUnread
23 24
import androidx.compose.material3.Card
25 +
import androidx.compose.material3.CardDefaults
24 26
import androidx.compose.material3.HorizontalDivider
25 27
import androidx.compose.material3.Icon
26 28
import androidx.compose.material3.ListItem
…
11 unchanged lines
38 40
import androidx.lifecycle.compose.collectAsStateWithLifecycle
39 41
import io.oxygenhub.android.api.Inbox
40 42
import io.oxygenhub.android.api.Repository
43 +
import io.oxygenhub.android.ui.backdrop.Backdrop
44 +
import io.oxygenhub.android.ui.backdrop.skyScene
41 45
import io.oxygenhub.android.ui.common.AppScreen
42 46
import io.oxygenhub.android.ui.common.AvatarImage
43 47
import io.oxygenhub.android.ui.common.Load
…
14 unchanged lines
58 62
    val state by vm.state.collectAsStateWithLifecycle()
59 63
    AppScreen(title = "Dashboard", subtitle = hub.substringAfter("://")) {
60 64
        LoadContent(state, onRefresh = { vm.refresh(); onRefreshInbox() }) { d ->
61 -
            DashboardContent(d, inbox, onOpenRepository, onAllRepositories, onInbox, onOpenProposal)
65 +
            // Your sky behind it, as on the hub's dashboard (#23): a finger draws it near and names a repository.
66 +
            Backdrop(key = d.sky, make = { still -> skyScene(d.sky, still) }, modifier = Modifier.fillMaxSize()) {
67 +
                DashboardContent(d, inbox, onOpenRepository, onAllRepositories, onInbox, onOpenProposal)
68 +
            }
62 69
        }
63 70
    }
64 71
}
…
52 unchanged lines
117 124
118 125
@Composable
119 126
private fun ProposalCard(title: String, list: List<ProposalBrief>, onOpen: (ProposalBrief) -> Unit) {
120 -
    Card(Modifier.fillMaxWidth()) {
127 +
    Card(Modifier.fillMaxWidth(), colors = glass()) {
121 128
        Text(
122 129
            "$title · ${list.size}",
123 130
            style = MaterialTheme.typography.titleMedium,
…
24 unchanged lines
148 155
149 156
@Composable
150 157
private fun Unread(inbox: Load<Inbox>, onInbox: () -> Unit) {
151 -
    Card(Modifier.fillMaxWidth().clickable(onClick = onInbox)) {
158 +
    Card(Modifier.fillMaxWidth().clickable(onClick = onInbox), colors = glass()) {
152 159
        ListItem(
153 160
            colors = ListItemDefaults.colors(containerColor = Color.Transparent),
154 161
            leadingContent = {
…
19 unchanged lines
174 181
175 182
@Composable
176 183
private fun Recent(d: Dashboard, onOpenRepository: (Repository) -> Unit, onAllRepositories: () -> Unit) {
177 -
    Card(Modifier.fillMaxWidth()) {
184 +
    Card(Modifier.fillMaxWidth(), colors = glass()) {
178 185
        Text(
179 186
            if (d.recentAreYours) "Your recent repositories" else "Recently updated on the hub",
180 187
            style = MaterialTheme.typography.titleMedium,
…
17 unchanged lines
198 205
        TextButton(onClick = onAllRepositories, modifier = Modifier.padding(8.dp)) { Text("All repositories") }
199 206
    }
200 207
}
208 +
209 +
/** A card the sky shows through a little. */
210 +
@Composable
211 +
private fun glass() = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.8f))
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardViewModel.kt +14 −0
…
4 unchanged lines
5 5
import io.oxygenhub.android.api.HubApi
6 6
import io.oxygenhub.android.api.Repository
7 7
import io.oxygenhub.android.api.User
8 +
import io.oxygenhub.android.api.UserSky
8 9
import io.oxygenhub.android.api.UserProposals
9 10
import io.oxygenhub.android.auth.Session
10 11
import io.oxygenhub.android.ui.common.Avatars
…
22 unchanged lines
33 34
    val recent: List<Repository>,
34 35
    val recentAreYours: Boolean,
35 36
    val repositoryCount: Int,
37 +
    /** Your sky (#23), the backdrop's data; null when the hub cannot give it. */
38 +
    val sky: UserSky? = null,
36 39
)
37 40
38 41
class DashboardViewModel(
…
35 unchanged lines
74 77
                null
75 78
            }
76 79
        }
80 +
        // As is the sky behind it all.
81 +
        val sky = async {
82 +
            try {
83 +
                api.userSky()
84 +
            } catch (e: CancellationException) {
85 +
                throw e
86 +
            } catch (_: Exception) {
87 +
                null
88 +
            }
89 +
        }
77 90
        // As is the avatar: its failure leaves the last one, or the initial.
78 91
        val avatar = async { Avatars.load(api, session.username) }
79 92
        val repos = api.repositories()
…
10 unchanged lines
90 103
            recent = pool.sortedByDescending { it.updatedMs }.take(RECENT),
91 104
            recentAreYours = mine.isNotEmpty(),
92 105
            repositoryCount = mine.size,
106 +
            sky = sky.await(),
93 107
        )
94 108
    }
95 109
…
13 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/flow/FlowScreens.kt +43 −11
1 1
package io.oxygenhub.android.ui.flow
2 2
3 -
import androidx.compose.material.icons.outlined.Bolt
4 -
import androidx.compose.material3.Button
5 -
import androidx.compose.material3.OutlinedButton
6 -
import androidx.compose.material3.TextButton
7 -
import androidx.compose.material3.AlertDialog
8 -
import androidx.compose.material3.OutlinedTextField
9 -
import androidx.compose.runtime.saveable.rememberSaveable
10 -
import androidx.compose.runtime.mutableStateOf
11 -
import androidx.compose.runtime.setValue
3 +
import androidx.compose.animation.core.LinearOutSlowInEasing
4 +
import androidx.compose.animation.core.animateFloat
5 +
import androidx.compose.animation.core.infiniteRepeatable
6 +
import androidx.compose.animation.core.rememberInfiniteTransition
7 +
import androidx.compose.animation.core.tween
8 +
import androidx.compose.foundation.Canvas
12 9
import androidx.compose.foundation.clickable
13 10
import androidx.compose.foundation.horizontalScroll
14 11
import androidx.compose.foundation.layout.Arrangement
12 +
import androidx.compose.foundation.layout.Box
15 13
import androidx.compose.foundation.layout.Column
16 14
import androidx.compose.foundation.layout.Row
17 15
import androidx.compose.foundation.layout.fillMaxSize
18 16
import androidx.compose.foundation.layout.fillMaxWidth
19 17
import androidx.compose.foundation.layout.padding
18 +
import androidx.compose.foundation.layout.size
20 19
import androidx.compose.foundation.layout.width
21 20
import androidx.compose.foundation.lazy.LazyColumn
22 21
import androidx.compose.foundation.lazy.items
…
2 unchanged lines
25 24
import androidx.compose.foundation.text.selection.SelectionContainer
26 25
import androidx.compose.material.icons.Icons
27 26
import androidx.compose.material.icons.outlined.Block
27 +
import androidx.compose.material.icons.outlined.Bolt
28 28
import androidx.compose.material.icons.outlined.CheckCircle
29 29
import androidx.compose.material.icons.outlined.Error
30 30
import androidx.compose.material.icons.outlined.HourglassEmpty
…
1 unchanged lines
32 32
import androidx.compose.material.icons.outlined.RemoveCircleOutline
33 33
import androidx.compose.material.icons.outlined.Replay
34 34
import androidx.compose.material.icons.outlined.RocketLaunch
35 +
import androidx.compose.material3.AlertDialog
36 +
import androidx.compose.material3.Button
35 37
import androidx.compose.material3.HorizontalDivider
36 38
import androidx.compose.material3.Icon
37 39
import androidx.compose.material3.IconButton
38 40
import androidx.compose.material3.ListItem
39 41
import androidx.compose.material3.MaterialTheme
42 +
import androidx.compose.material3.OutlinedButton
43 +
import androidx.compose.material3.OutlinedTextField
40 44
import androidx.compose.material3.Text
45 +
import androidx.compose.material3.TextButton
41 46
import androidx.compose.runtime.Composable
42 47
import androidx.compose.runtime.LaunchedEffect
43 48
import androidx.compose.runtime.getValue
49 +
import androidx.compose.runtime.mutableStateOf
50 +
import androidx.compose.runtime.saveable.rememberSaveable
51 +
import androidx.compose.runtime.setValue
52 +
import androidx.compose.ui.Alignment
44 53
import androidx.compose.ui.Modifier
45 54
import androidx.compose.ui.graphics.Color
55 +
import androidx.compose.ui.graphics.drawscope.Stroke
46 56
import androidx.compose.ui.graphics.vector.ImageVector
47 57
import androidx.compose.ui.text.font.FontFamily
48 58
import androidx.compose.ui.unit.dp
…
20 unchanged lines
69 79
        "cancelled" -> Icons.Outlined.Block to MaterialTheme.colorScheme.onSurfaceVariant
70 80
        "skipped" -> Icons.Outlined.RemoveCircleOutline to MaterialTheme.colorScheme.onSurfaceVariant
71 81
        else -> Icons.Outlined.HourglassEmpty to MaterialTheme.colorScheme.onSurfaceVariant
82 +
    }
83 +
}
84 +
85 +
/**
86 +
 * A status's icon; a live one pulses, a ring breathing out from it in its
87 +
 * colour (#23), as live things pulse on the hub's pages.
88 +
 */
89 +
@Composable
90 +
fun StatusIcon(status: String, icon: ImageVector, colour: Color) {
91 +
    Box(contentAlignment = Alignment.Center) {
92 +
        if (status == "running") {
93 +
            val t by rememberInfiniteTransition(label = "live").animateFloat(
94 +
                0f,
95 +
                1f,
96 +
                infiniteRepeatable(tween(1400, easing = LinearOutSlowInEasing)),
97 +
                label = "pulse",
98 +
            )
99 +
            Canvas(Modifier.size(24.dp)) {
100 +
                drawCircle(colour.copy(alpha = 0.45f * (1 - t)), radius = size.minDimension / 2 * (0.8f + 0.9f * t), style = Stroke(2.dp.toPx()))
101 +
            }
102 +
        }
103 +
        Icon(icon, contentDescription = status, tint = colour)
72 104
    }
73 105
}
74 106
…
10 unchanged lines
85 117
                    val (icon, colour) = statusLook(r.status)
86 118
                    ListItem(
87 119
                        modifier = Modifier.clickable { onOpen(r) },
88 -
                        leadingContent = { Icon(icon, contentDescription = r.status, tint = colour) },
120 +
                        leadingContent = { StatusIcon(r.status, icon, colour) },
89 121
                        headlineContent = { Text("#${r.number} ${r.bookmark}") },
90 122
                        supportingContent = {
91 123
                            Text(
…
43 unchanged lines
135 167
                    val (icon, colour) = statusLook(j.status)
136 168
                    ListItem(
137 169
                        modifier = Modifier.clickable { onOpenJob(j) },
138 -
                        leadingContent = { Icon(icon, contentDescription = j.status, tint = colour) },
170 +
                        leadingContent = { StatusIcon(j.status, icon, colour) },
139 171
                        headlineContent = { Text(j.name) },
140 172
                        supportingContent = {
141 173
                            Column {
…
98 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/signin/SignInScreen.kt +19 −9
…
34 unchanged lines
35 35
import androidx.compose.runtime.getValue
36 36
import androidx.compose.ui.Alignment
37 37
import androidx.compose.ui.Modifier
38 +
import androidx.compose.ui.graphics.Color
38 39
import androidx.compose.ui.platform.LocalUriHandler
39 40
import androidx.compose.ui.res.painterResource
40 41
import androidx.compose.ui.semantics.contentDescription
…
8 unchanged lines
49 50
import androidx.lifecycle.compose.collectAsStateWithLifecycle
50 51
import io.oxygenhub.android.R
51 52
import io.oxygenhub.android.api.DeviceCode
53 +
import io.oxygenhub.android.ui.backdrop.Backdrop
54 +
import uniffi.rust_core.Backdrop as Scene
52 55
53 56
@Composable
54 57
fun SignInScreen(vm: SignInViewModel) {
55 58
    val hub by vm.hub.collectAsStateWithLifecycle()
56 59
    val state by vm.state.collectAsStateWithLifecycle()
57 60
    val ended by vm.ended.collectAsStateWithLifecycle()
58 -
    SignInContent(
59 -
        hub = hub,
60 -
        state = state,
61 -
        notice = ended,
62 -
        onHubChange = vm::setHub,
63 -
        onSignIn = vm::signIn,
64 -
        onCancel = vm::cancel,
65 -
    )
61 +
    // The OxygenHub mark behind it, as behind the hub's sign-in page (#23): it answers a finger.
62 +
    Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
63 +
        Backdrop(key = Unit, make = { still -> Scene.mark(still) }, modifier = Modifier.fillMaxSize()) {
64 +
            SignInContent(
65 +
                hub = hub,
66 +
                state = state,
67 +
                notice = ended,
68 +
                onHubChange = vm::setHub,
69 +
                onSignIn = vm::signIn,
70 +
                onCancel = vm::cancel,
71 +
                background = Color.Transparent,
72 +
            )
73 +
        }
74 +
    }
66 75
}
67 76
68 77
@Composable
…
4 unchanged lines
73 82
    onHubChange: (String) -> Unit,
74 83
    onSignIn: () -> Unit,
75 84
    onCancel: () -> Unit,
85 +
    background: Color = MaterialTheme.colorScheme.background,
76 86
) {
77 -
    Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
87 +
    Surface(Modifier.fillMaxSize(), color = background) {
78 88
        Box(Modifier.fillMaxSize().safeDrawingPadding().imePadding(), contentAlignment = Alignment.Center) {
79 89
            Column(
80 90
                Modifier
…
100 unchanged lines
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +4 −0
…
160 unchanged lines
161 161
    override suspend fun change(owner: String, name: String, hash: String) = change.invoke(hash).also { calls += "change $hash" }
162 162
    var refs: Pair<List<String>, List<String>> = listOf("main") to emptyList()
163 163
    override suspend fun refs(owner: String, name: String) = refs.also { calls += "refs" }
164 +
    var userSky: () -> io.oxygenhub.android.api.UserSky = {
165 +
        io.oxygenhub.android.api.UserSky(now = 10 * 86_400_000L, repos = listOf("andymccall/oxygen"), events = listOf(listOf(0L, 0L, 9 * 86_400_000L, 3L)))
166 +
    }
167 +
    override suspend fun userSky() = userSky.invoke().also { calls += "user sky" }
164 168
    override suspend fun userProposals(landable: Boolean) =
165 169
        userProposals.invoke().also { calls += if (landable) "user proposals, landable" else "user proposals" }
166 170
    override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> =
…
25 unchanged lines
modified app/src/test/java/io/oxygenhub/android/ui/DashboardViewModelTest.kt +40 −0
…
4 unchanged lines
5 5
import io.oxygenhub.android.MainDispatcherRule
6 6
import io.oxygenhub.android.api.Avatar
7 7
import io.oxygenhub.android.api.HubException
8 +
import io.oxygenhub.android.api.UserSky
8 9
import io.oxygenhub.android.auth.Session
10 +
import io.oxygenhub.android.ui.backdrop.Frame
11 +
import io.oxygenhub.android.ui.backdrop.skyEvents
12 +
import io.oxygenhub.android.ui.backdrop.straight
9 13
import io.oxygenhub.android.ui.common.Load
10 14
import io.oxygenhub.android.ui.dashboard.Dashboard
11 15
import io.oxygenhub.android.ui.dashboard.DashboardViewModel
…
35 unchanged lines
47 51
    }
48 52
49 53
    @Test
54 +
    fun drawsYourSkyFromTheHubAndTheFieldWithoutIt() {
55 +
        val vm = DashboardViewModel(api, session) { 9 }
56 +
        val sky = ready(vm).sky!!
57 +
        val events = skyEvents(sky)
58 +
        assertEquals(1, events.size)
59 +
        assertEquals(1f, events[0].ageDays, 0.001f)
60 +
        assertEquals(3u, events[0].n)
61 +
        // A hub before /user/sky: the dashboard loads, with no sky.
62 +
        api.userSky = { throw HubException.Http(404, "no such endpoint") }
63 +
        vm.refresh()
64 +
        assertNull(ready(vm).sky)
65 +
        assertEquals(2, ready(vm).repositoryCount)
66 +
    }
67 +
68 +
    @Test
69 +
    fun leavesOutMalformedSkyEvents() {
70 +
        val sky = UserSky(now = 0, repos = listOf("a/b"), events = listOf(listOf(0L, 9L, 0L, 1L), listOf(3L, 0L, 0L, 1L), listOf(0L), listOf(0L, 4L, 5L, 0L)))
71 +
        val events = skyEvents(sky)
72 +
        assertEquals(1, events.size)
73 +
        assertEquals(4u, events[0].kind)
74 +
        assertEquals(0f, events[0].ageDays)
75 +
        assertEquals("at least one mark", 1u, events[0].n)
76 +
    }
77 +
78 +
    @Test
79 +
    fun decodesAFrameAndUnpremultipliesItsColours() {
80 +
        val bytes = java.nio.ByteBuffer.allocate(8).order(java.nio.ByteOrder.LITTLE_ENDIAN).putFloat(1.5f).putFloat(-2f).array()
81 +
        assertTrue(Frame.floats(bytes).contentEquals(floatArrayOf(1.5f, -2f)))
82 +
        val c = straight(0.25f, 0f, 0.5f, 0.5f)
83 +
        assertEquals(0.5f, c.red, 0.01f)
84 +
        assertEquals(1f, c.blue, 0.01f)
85 +
        assertEquals(0.5f, c.alpha, 0.01f)
86 +
        assertEquals(0f, straight(0.1f, 0.1f, 0.1f, 0f).alpha)
87 +
    }
88 +
89 +
    @Test
50 90
    fun showsProposalsToReviewAndNoneFromAHubWithoutTheCall() {
51 91
        val vm = DashboardViewModel(api, session) { 9 }
52 92
        assertEquals(listOf(300L), ready(vm).proposals?.review?.map { it.number })
…
44 unchanged lines
modified rust-core/src/lib.rs +3 −0
1 1
uniffi::setup_scaffolding!();
2 2
3 +
mod backdrop;
4 +
pub mod scenes;
5 +
3 6
use std::path::Path;
4 7
5 8
// A simple test function to ensure the Kotlin <-> Rust bridge is working.
…
101 unchanged lines