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 |