diff options
| author | user <user@clank> | 2026-08-19 14:30:28 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-19 14:30:28 +0200 |
| commit | 20e1e104f2254c914b464a147aa0368491cdeb0c (patch) | |
| tree | ea3d1d0618426f7cdad703ad854b4ce07e06e9b3 | |
| parent | slosh: the splash colours glyphs, never backgrounds (diff) | |
| download | slosh-20e1e104f2254c914b464a147aa0368491cdeb0c.tar.gz slosh-20e1e104f2254c914b464a147aa0368491cdeb0c.tar.xz slosh-20e1e104f2254c914b464a147aa0368491cdeb0c.zip | |
slosh: the logo flies into place -- a particle engine, statelessly
Every non-space glyph of the logo is a particle that flies to its cell
over the first two fifths of splash_ms, then *is* the logo for the
rest. There is deliberately no particle state anywhere: a position is
a pure function of (target, elapsed, seed), recomputed every frame
like the layout and the pane states and for the same reason -- physics
that is stored can drift, physics that is derived cannot. The seed is
the splash timestamp, so every attach flies differently and one splash
is coherent frame to frame; a per-particle hash staggers departures,
and the delay rescaling lands every straggler exactly at the end,
because an almost-assembled logo reads as a bug.
Five motions, picked by the clock and pinned by {"cmd":"splash",
"motion":N}: rain (fall in, column by column), scatter (converge from
everywhere), slide (rows enter from alternating sides), vortex (spiral
in around the centre, orbit shrinking and unwinding), shuffle (the
glyphs trade places, then sort themselves). The colour chains keep
playing over the box throughout, so particles catch their hue as they
cross into it.
expr_sin() is the expression language's own sine exported for C-side
animation: a motion sketched in the repl and one compiled into the
binary cannot disagree about what sin(90) is.
The tests learned two things the flights taught: the logo's opening
run appears three times per line and assembly is staggered, so a
middle occurrence can land first -- "assembled" is the row whose first
block glyph is where the match begins -- and windows need margins that
survive a parallel suite on a busy box.
| -rw-r--r-- | include/app.h | 10 | ||||
| -rw-r--r-- | include/app_internal.h | 6 | ||||
| -rw-r--r-- | include/expr.h | 4 | ||||
| -rw-r--r-- | src/app.c | 5 | ||||
| -rw-r--r-- | src/app_draw.c | 147 | ||||
| -rw-r--r-- | src/cmd.c | 8 | ||||
| -rw-r--r-- | src/expr.c | 5 | ||||
| -rw-r--r-- | tests/test_splash.py | 103 |
8 files changed, 239 insertions, 49 deletions
diff --git a/include/app.h b/include/app.h index 381c3c0..33fcbea 100644 --- a/include/app.h +++ b/include/app.h @@ -66,11 +66,13 @@ size_t app_fds(app_t *a, int *out, size_t max); void app_reap(app_t *a); void app_compose(app_t *a, screen_t *s); -/* Show the logo splash for splash_ms, centered over everything, wearing a - * different shader effect each time. Called on attach; any input ends it. - * app_splash_fx picks the effect by index (wrapped); -1 lets the clock. */ +/* Show the logo splash for splash_ms, centered over everything: the glyphs + * fly into place first (a particle motion), then a colour effect plays over + * them. Both are picked by the clock each time; app_splash_fx pins either by + * index (wrapped), -1 to leave a pick to the clock. Called on attach; any + * input ends it. */ void app_splash(app_t *a); -void app_splash_fx(app_t *a, int fx); +void app_splash_fx(app_t *a, int fx, int motion); /* Bytes straight into the focused pane's pty (the harness's `raw`). */ void app_write_focused(app_t *a, const void *buf, size_t len); bool app_should_quit(const app_t *a); diff --git a/include/app_internal.h b/include/app_internal.h index 9af6a04..b3a64cb 100644 --- a/include/app_internal.h +++ b/include/app_internal.h @@ -264,10 +264,12 @@ struct app { /* The logo splash: shown until this instant, 0 for not at all. Set when a * client attaches (app_splash), ended early by any key or click. The - * timestamp doubles as the seed that picks which effect this one wears, - * unless splash_fx (>= 0, from the control API) already chose. */ + * timestamp doubles as the seed that picks the colour effect and the + * particle motion this one wears, unless splash_fx / splash_motion (>= 0, + * from the control API) already chose. */ int64_t splash_until; int splash_fx; + int splash_motion; }; /* One config per process, owned by app.c (ensure_config/app_reload_config diff --git a/include/expr.h b/include/expr.h index d593319..28eeabc 100644 --- a/include/expr.h +++ b/include/expr.h @@ -93,6 +93,10 @@ int expr_eval(const expr_prog_t *p, const expr_env_t *env); * pretend an expression was a number. */ const char *expr_source(const expr_prog_t *p); +/* The language's sine, for C code that animates: degrees in (any angle + * wraps), -255..255 out -- the same table `sin()` in an expression uses. */ +int expr_sin(int deg); + /* Evaluate for every cell of a cols x rows rect into `out` (cols*rows bytes, * clamped to 0..255), reusing the last result when nothing it reads has * changed. Returns false when the program reads the clock, which is the one @@ -1312,12 +1312,13 @@ bool run_action(app_t *a, action_t act) { } } -void app_splash(app_t *a) { app_splash_fx(a, -1); } +void app_splash(app_t *a) { app_splash_fx(a, -1, -1); } -void app_splash_fx(app_t *a, int fx) { +void app_splash_fx(app_t *a, int fx, int motion) { if (!CFG.splash_ms) return; a->splash_until = now_ms_() + CFG.splash_ms; a->splash_fx = fx; + a->splash_motion = motion; } void app_event(app_t *a, const input_event_t *ev) { diff --git a/src/app_draw.c b/src/app_draw.c index 20ae857..7c2a93c 100644 --- a/src/app_draw.c +++ b/src/app_draw.c @@ -1812,6 +1812,124 @@ static const splash_fx_t SPLASH_FX[] = { }; #define NSPLASH_FX (sizeof SPLASH_FX / sizeof *SPLASH_FX) +/* ---- the particle engine ------------------------------------------------- * + * + * Every non-space glyph of the logo is a particle that flies to its cell over + * the first two fifths of splash_ms, then *is* the logo for the rest. There + * is deliberately no particle state anywhere: a particle's position is a pure + * function of (target, elapsed, seed), recomputed every frame like the layout + * and the pane states and for the same reason -- physics that is stored can + * drift, physics that is derived cannot. The seed is the splash timestamp, so + * every attach flies differently and a single splash is coherent frame to + * frame; a per-particle hash of it staggers departures and scatters spawns. */ + +typedef struct { + int tx, ty; /* target, in screen cells */ + char glyph[8]; /* one UTF-8 cluster */ +} splash_p_t; +#define SPLASH_P_MAX 512 + +/* The logo as particles, re-derived per frame: ~300 tiny copies against a + * struct someone has to keep in sync with logo.txt is no contest. */ +static size_t splash_particles(splash_p_t *out, int x0, int y0) { + size_t n = 0, nlines = sizeof LOGO / sizeof *LOGO; + for (size_t ly = 0; ly < nlines; ly++) { + const char *sline = LOGO[ly]; + int cx = 0; + for (size_t off = 0; sline[off] && n < SPLASH_P_MAX; cx++) { + unsigned char lead = (unsigned char)sline[off]; + size_t len = lead < 0x80 ? 1 : lead < 0xE0 ? 2 : lead < 0xF0 ? 3 : 4; + if (sline[off] != ' ') { + out[n].tx = x0 + cx; + out[n].ty = y0 + (int)ly; + memcpy(out[n].glyph, sline + off, len); + out[n].glyph[len] = 0; + n++; + } + off += len; + } + } + return n; +} + +/* Ease-out cubic in 0..256 fixed point: fast off the line, gentle landing. */ +static int splash_ease(int p8) { + int q = 256 - p8; + return 256 - q * q / 256 * q / 256; +} + +/* This particle's progress, 0..256. `delay` (0..255) eats the front of the + * window and the remainder is rescaled, so stragglers still land exactly at + * the end -- an assembly that is only mostly assembled reads as a bug. */ +static int splash_p8(int64_t since, int asm_ms, int delay) { + if (asm_ms <= 0) return 256; + int p = (int)(since * 256 / asm_ms); + if (p >= 256) return 256; + if (delay > 224) delay = 224; + p = (p - delay) * 256 / (256 - delay); + return p < 0 ? 0 : p > 256 ? 256 : p; +} + +/* The motions. Each computes where particle i is *from* and how late it + * leaves; the flight itself is one shared eased lerp, plus whatever offset + * the motion adds along the way (the vortex's orbit). Coordinates may leave + * the screen; the draw clips. */ +enum { + SPLASH_MO_RAIN, /* fall in, column by column */ + SPLASH_MO_SCATTER, /* converge from everywhere */ + SPLASH_MO_SLIDE, /* rows enter from alternating sides */ + SPLASH_MO_VORTEX, /* spiral in around the centre */ + SPLASH_MO_SHUFFLE, /* the glyphs trade places, then sort themselves */ + NSPLASH_MO +}; + +static void splash_place(int motion, const splash_p_t *p, size_t i, size_t n, + const splash_p_t *all, uint32_t seed, + const screen_t *s, int64_t since, int asm_ms, int *ox, + int *oy) { + uint32_t h = ((uint32_t)i * 2654435761u) ^ (seed * 2246822519u); + int sx = p->tx, sy = p->ty, delay = 0; + switch (motion) { + case SPLASH_MO_RAIN: + sy = -1 - (int)(h % 12); + delay = (int)(h % 160); + break; + case SPLASH_MO_SCATTER: + sx = (int)(h % (s->cols ? s->cols : 1)); + sy = (int)((h >> 9) % (s->rows ? s->rows : 1)); + delay = (int)((h >> 18) % 96); + break; + case SPLASH_MO_SLIDE: + sx = (p->ty % 2) ? (int)s->cols + 2 + (int)(h % 8) : -3 - (int)(h % 8); + delay = (p->ty * 31) % 128; + break; + case SPLASH_MO_SHUFFLE: { + /* Everybody starts on somebody else's cell: a fixed rotation of the + * particle list, which is a permutation by construction. */ + size_t j = (i + 1 + seed % (n > 1 ? n - 1 : 1)) % n; + sx = all[j].tx; + sy = all[j].ty; + delay = (int)(h % 112); + break; + } + case SPLASH_MO_VORTEX: + default: delay = (int)(h % 64); break; + } + + int e = splash_ease(splash_p8(since, asm_ms, delay)); + *ox = sx + (p->tx - sx) * e / 256; + *oy = sy + (p->ty - sy) * e / 256; + + if (motion == SPLASH_MO_VORTEX && e < 256) { + /* An orbit that shrinks and unwinds as the flight completes. dist counts + * a row double everywhere else, so the y radius is halved here too. */ + int r = (24 + (int)(h % 20)) * (256 - e) / 256; + int ang = (int)(h % 360) + e * 2; + *ox += r * expr_sin(ang + 90) / 255; + *oy += r * expr_sin(ang) / 510; + } +} + static color_t splash_rgb(uint32_t rgb) { return (color_t){.set = true, .r = (uint8_t)(rgb >> 16), @@ -1847,12 +1965,27 @@ void draw_splash(app_t *a, screen_t *s) { blank[nb] = 0; for (uint16_t y = 0; y < bh; y++) screen_text(s, x0, (uint16_t)(y0 + y), blank, NO_COLOR, NO_COLOR, 0); - /* White, not the accent: the effect passes below paint the hues, and white - * is the canvas that takes every one of them at full saturation -- tinting - * an already-blue glyph towards pink lands on mud. */ - for (size_t i = 0; i < nlines; i++) - screen_text(s, (uint16_t)(x0 + 2), (uint16_t)(y0 + 1 + i), LOGO[i], - CFG.default_fg, NO_COLOR, ATTR_BOLD); + /* The glyphs, wherever their flight has them this frame. White, not the + * accent: the effect passes below paint the hues, and white is the canvas + * that takes every one of them at full saturation -- tinting an + * already-blue glyph towards pink lands on mud. Assembly takes the first + * two fifths of splash_ms; from then on every particle sits on its target + * and this is exactly the static logo. */ + splash_p_t parts[SPLASH_P_MAX]; + size_t np = splash_particles(parts, x0 + 2, y0 + 1); + int64_t since = now - (a->splash_until - CFG.splash_ms); + int asm_ms = (int)CFG.splash_ms * 2 / 5; + uint32_t seed = (uint32_t)a->splash_until; + int motion = a->splash_motion >= 0 ? a->splash_motion % NSPLASH_MO + : (int)((seed / NSPLASH_FX) % NSPLASH_MO); + for (size_t i = 0; i < np; i++) { + int px, py; + splash_place(motion, &parts[i], i, np, parts, seed, s, since, asm_ms, &px, + &py); + if (px < 0 || py < 0 || px >= s->cols || py >= s->rows) continue; + screen_text(s, (uint16_t)px, (uint16_t)py, parts[i].glyph, CFG.default_fg, + NO_COLOR, ATTR_BOLD); + } /* The effect chain. Programs are compiled on first wear and kept for the * life of the process: the sources are string literals above, and a splash @@ -1879,7 +2012,7 @@ void draw_splash(app_t *a, screen_t *s) { shade_ctx_t ctx = { .now_ms = now, - .state_ms = now - (a->splash_until - CFG.splash_ms), + .state_ms = since, /* the splash's own age, for one-shot effects */ .default_fg = CFG.default_fg, .default_bg = CFG.default_bg, }; @@ -429,9 +429,11 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in, if (strcmp(cmd, "splash") == 0) { /* The attach greeting, on demand -- which is also what makes it testable * from a scripted session, where nobody ever attaches. `fx` picks the - * effect by index instead of by the clock: a test needs a deterministic - * one, and "show me the aurora again" deserves an answer too. */ - app_splash_fx(a, (int)jv_geti(req, "fx", -1)); + * colour effect and `motion` the particle assembly by index instead of + * by the clock: a test needs a deterministic one, and "show me the + * aurora again" deserves an answer too. */ + app_splash_fx(a, (int)jv_geti(req, "fx", -1), + (int)jv_geti(req, "motion", -1)); return jok_int(NULL, 0); } if (strcmp(cmd, "reload") == 0) { @@ -762,6 +762,11 @@ void expr_free(expr_prog_t *p) { const char *expr_source(const expr_prog_t *p) { return p ? p->src : NULL; } +/* The language's own sine, exported: C-side animation (the splash particles) + * wants the same table the expressions use, so a motion sketched in the repl + * and one compiled into the binary cannot disagree about what sin(90) is. */ +int expr_sin(int deg) { return (int)isin(deg); } + unsigned expr_deps(const expr_prog_t *p) { return p ? p->deps : 0; } int expr_constant(const expr_prog_t *p) { return p ? p->value : 0; } diff --git a/tests/test_splash.py b/tests/test_splash.py index 4e01f15..efbd7ec 100644 --- a/tests/test_splash.py +++ b/tests/test_splash.py @@ -3,8 +3,10 @@ A scripted session never attaches, so these drive it through the control API's `splash` verb -- the same call the server makes on MSG_HELLO. What is -asserted is the contract: centered, temporary, ended early by input, and -absent when splash_ms says never. +asserted is the contract: the glyphs fly in and land exactly, centered, +temporary, colourful, ended early by input, and absent when splash_ms says +never. Assembly takes the first two fifths of splash_ms, which is why each +test picks a duration to suit what it is watching. """ import os @@ -15,6 +17,11 @@ from harness import Session, check, report SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; stty raw -echo; cat'] GLYPHS = "\u2584\u2588\u2588\u2588\u2588\u2588\u2588\u2584" # ▄██████▄, line one +BLOCKS = ("\u2584", "\u2588", "\u2580") # any glyph a particle could be + + +def flying(snap): + return any(b in snap.screen() for b in BLOCKS) def cfg(text): @@ -24,43 +31,59 @@ def cfg(text): return f.name -def test_the_splash_shows_centered_and_goes_away(): - conf = cfg("splash_ms 400\n") +def test_the_splash_assembles_centered_and_goes_away(): + conf = cfg("splash_ms 1600\n") # assembly done by 640ms, margin under load with Session(SH, cols=80, rows=24, config=conf) as s: s.settle(30) - check("no greeting until asked", GLYPHS not in s.snapshot().screen(), "") + check("no greeting until asked", not flying(s.snapshot()), "") s.api("splash") - snap = s.snapshot() - check("the logo is up", GLYPHS in snap.screen(), snap.screen()[:200]) + + # The logo's first line contains its opening run three times over, and + # assembly is staggered, so a middle occurrence can land before the + # leftmost: "assembled" is the row whose *first* block glyph is where + # the match begins. + def landed_row(sn): + for i, line in enumerate(sn.text): + if GLYPHS in line and line.index(GLYPHS) == line.index("\u2584"): + return i + return None + + snap = s.until(lambda sn: landed_row(sn) is not None, timeout_ms=6000) + y = landed_row(snap) + check("the glyphs land as the logo", y is not None, snap.screen()[:200]) # Centered: the logo is 44 columns in an 80-column screen, so its # first glyph lands at (80 - 44) / 2 give or take the box margin. - rows = [i for i, line in enumerate(snap.text) if GLYPHS in line] - line = snap.text[rows[0]] - x = line.index("\u2584") + x = snap.text[y].index(GLYPHS) if y is not None else -1 check("h-centered", 14 <= x <= 22, f"x={x}") - check("v-centered", 6 <= rows[0] <= 12, f"y={rows[0]}") + check("v-centered", y is not None and 6 <= y <= 12, f"y={y}") - snap = s.until(lambda sn: GLYPHS not in sn.screen()) - check("and it goes away by itself", GLYPHS not in snap.screen(), "") + snap = s.until(lambda sn: not flying(sn)) + check("and it goes away by itself", not flying(snap), "") os.unlink(conf) -def test_any_key_ends_it_and_still_lands(): - conf = cfg("splash_ms 10000\n") # long enough that expiry cannot pass this +def test_every_motion_lands_exactly(): + """Five ways to fly in, one place to land: whatever the motion, assembly + ends with every particle on its cell -- an almost-assembled logo reads as + a bug, which is what the per-particle delay rescaling exists to prevent.""" + conf = cfg("splash_ms 1600\n") # landed window: 960ms, wide enough under load with Session(SH, cols=80, rows=24, config=conf) as s: s.settle(30) - s.api("splash") - check("up", GLYPHS in s.snapshot().screen(), "") - s.send("marker") # plain keys: dismiss the greeting, reach the shell - snap = s.until(lambda sn: GLYPHS not in sn.screen()) - check("a keystroke puts it away", GLYPHS not in snap.screen(), "") - snap = s.until_text("marker") - check( - "...and still reaches the pane it was aimed at", - "marker" in snap.screen(), - snap.screen()[:200], - ) + for motion in range(5): + s.api("splash", fx=0, motion=motion) + # Landed means the line exists *and* nothing is still passing to + # its left: the first block glyph on that row is the logo's own. + landed = lambda sn: any( + GLYPHS in line and line.index("\u2584") == 18 for line in sn.text + ) + snap = s.until(landed, timeout_ms=6000) + check( + f"motion {motion} lands the logo, exactly placed", + landed(snap), + snap.screen()[:200], + ) + s.until(lambda sn: not flying(sn), timeout_ms=6000) # expire between rounds os.unlink(conf) @@ -70,12 +93,11 @@ def test_the_splash_is_colourful_not_a_tinted_logo(): the logo must show genuinely different *hues* -- counted as dominant- channel families, because two hundred shades of the same blue is exactly the failure this guards against.""" - conf = cfg("splash_ms 10000\n") + conf = cfg("splash_ms 2500\n") # assembled by 1000ms, coloured until 2500 with Session(SH, cols=80, rows=24, config=conf) as s: s.settle(30) s.api("splash", fx=0) # spectrum: deterministic, and the loudest - s.settle(60) - snap = s.snapshot() + snap = s.until(lambda sn: GLYPHS in sn.screen(), timeout_ms=6000) rows = [i for i, line in enumerate(snap.text) if GLYPHS in line] families = set() for y in rows: @@ -94,13 +116,32 @@ def test_the_splash_is_colourful_not_a_tinted_logo(): os.unlink(conf) +def test_any_key_ends_it_mid_flight_and_still_lands(): + conf = cfg("splash_ms 10000\n") # long enough that expiry cannot pass this + with Session(SH, cols=80, rows=24, config=conf) as s: + s.settle(30) + s.api("splash") + snap = s.until(flying) + check("particles are in the air", flying(snap), "") + s.send("marker") # plain keys: dismiss the greeting, reach the shell + snap = s.until(lambda sn: not flying(sn)) + check("a keystroke puts the whole flight away", not flying(snap), "") + snap = s.until_text("marker") + check( + "...and still reaches the pane it was aimed at", + "marker" in snap.screen(), + snap.screen()[:200], + ) + os.unlink(conf) + + def test_splash_ms_zero_means_never(): conf = cfg("splash_ms 0\n") with Session(SH, cols=80, rows=24, config=conf) as s: s.settle(30) s.api("splash") s.settle(30) - check("nothing appears", GLYPHS not in s.snapshot().screen(), "") + check("nothing appears", not flying(s.snapshot()), "") os.unlink(conf) @@ -111,7 +152,7 @@ def test_a_screen_too_small_gets_no_greeting(): s.settle(30) s.api("splash") s.settle(30) - check("no logo on 40 columns", GLYPHS not in s.snapshot().screen(), "") + check("no logo on 40 columns", not flying(s.snapshot()), "") os.unlink(conf) |
