1
+//! Launch splash for the interactive terminal: the banner as an "ember
2
+//! wordmark". The letters of "siGit" are the blank cells in the art and the
3
+//! digits are a flame streaming off behind them, so the word faces right and
4
+//! the trail runs to its left. This is the same animation the siGit app plays
5
+//! at launch (`src/components/splash` in sigit-app); keep the timeline and the
6
+//! art in sync with it.
7
+//!
8
+//! Everything here is a pure function of elapsed time, so the frames can be
9
+//! tested without a terminal. The splash is not decoration: it stays up until
10
+//! the startup checks in `startup.rs` have reported, and never less than
11
+//! [`MIN_SHOW`].
12
+
13
+use std::time::{Duration, Instant};
14
+
15
+use ratatui::buffer::Buffer;
16
+use ratatui::layout::Rect;
17
+use ratatui::style::{Color, Style};
18
+use ratatui::widgets::Widget;
19
+
20
+/// The siGit Code banner. The blank cells in rows 2-11 are the letters; the
21
+/// digits are the flame. Mirrored in sigit-app's `bannerArt.ts`.
22
+pub(crate) const BANNER_ART: &str = "\
23
+77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777
24
+77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777
25
+1111111125555555555555555555555511113222311159 5002 088 3081771691111111111111
26
+1111111111111111111111111111131136841 1482853332007 05 9043332891 400811111111111
27
+1111111111111111111111111111111201 109 304 40 00 79 100041111111111
28
+333333255555555555555555555552392 102 503 90 7000000005 903 0000023333333333
29
+333333245454545454545454545433381 7600000 302 61 780 109 20009533333333333
30
+3333333333333333333333333333333402 7001 08 761 202 902 90003333333333333
31
+2222255555555555555555555555250899901 49 304 403 08 108 300042222222222222
32
+2222222222222222222222222222269 106 03 901 06 505 402 000052222222222222
33
+2222255555555555555555555555299 708 1002 80 00 90852222222222222
34
+55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555
35
+88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888";
36
+
37
+/// The splash never leaves before this, even when every check is instant.
38
+pub(crate) const MIN_SHOW: Duration = Duration::from_millis(1400);
39
+/// Contract and fade at the end.
40
+const EXIT: Duration = Duration::from_millis(350);
41
+/// A check that never reports must not hold the terminal hostage.
42
+pub(crate) const HARD_CAP: Duration = Duration::from_millis(4000);
43
+
44
+// Graphite canvas and one ember ramp, the same stops as the app's design tokens
45
+// (`--surface-950`, `--surface-600`, `--brand-600`, `--accent`, `--brand-400`,
46
+// `--ink-strong`, `--ink-muted`).
47
+type Rgb = [u8; 3];
48
+const BG: Rgb = [0x12, 0x14, 0x17];
49
+const RAMP: [Rgb; 4] = [
50
+ [0x2d, 0x32, 0x38],
51
+ [0xe8, 0x5d, 0x28],
52
+ [0xfe, 0x6d, 0x36],
53
+ [0xff, 0x8f, 0x5a],
54
+];
55
+pub(crate) const INK: Rgb = [0xf3, 0xf4, 0xf6];
56
+pub(crate) const MUTED: Rgb = [0x6b, 0x72, 0x80];
57
+pub(crate) const EMBER: Rgb = RAMP[2];
58
+
59
+/// Heat per digit. 0 and 9 are the densest in the art, 7 and 1 the thinnest.
60
+const HEAT: [f32; 10] = [1.0, 0.2, 0.45, 0.55, 0.5, 0.4, 0.65, 0.15, 0.8, 1.0];
61
+
62
+#[derive(Clone, Copy)]
63
+struct Cell {
64
+ x: u16,
65
+ y: u16,
66
+ letter: bool,
67
+ heat: f32,
68
+}
69
+
70
+/// The banner parsed into cells.
71
+pub(crate) struct Banner {
72
+ cols: u16,
73
+ rows: u16,
74
+ cells: Vec<Cell>,
75
+ letter_min_x: u16,
76
+ letter_max_x: u16,
77
+}
78
+
79
+impl Banner {
80
+ pub(crate) fn parse() -> Self {
81
+ let lines: Vec<&str> = BANNER_ART.lines().collect();
82
+ let cols = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16;
83
+ let mut cells = Vec::new();
84
+ let (mut letter_min_x, mut letter_max_x) = (cols, 0);
85
+ for (y, line) in lines.iter().enumerate() {
86
+ let chars: Vec<char> = line.chars().collect();
87
+ for x in 0..cols {
88
+ match chars.get(x as usize).copied().unwrap_or(' ') {
89
+ ' ' => {
90
+ if !(2..=11).contains(&y) {
91
+ continue;
92
+ }
93
+ cells.push(Cell {
94
+ x,
95
+ y: y as u16,
96
+ letter: true,
97
+ heat: 0.0,
98
+ });
99
+ letter_min_x = letter_min_x.min(x);
100
+ letter_max_x = letter_max_x.max(x);
101
+ }
102
+ ch => {
103
+ let heat = ch.to_digit(10).map_or(0.5, |d| HEAT[d as usize]);
104
+ cells.push(Cell {
105
+ x,
106
+ y: y as u16,
107
+ letter: false,
108
+ heat,
109
+ });
110
+ }
111
+ }
112
+ }
113
+ }
114
+ Self {
115
+ cols,
116
+ rows: lines.len() as u16,
117
+ cells,
118
+ letter_min_x,
119
+ letter_max_x,
120
+ }
121
+ }
122
+
123
+ /// First column to draw for a terminal `width` cells wide. A narrow
124
+ /// terminal keeps the word and a short tail and drops the rest of the
125
+ /// trail. `None` when even the word does not fit.
126
+ fn start_col(&self, width: u16) -> Option<u16> {
127
+ let word = self.letter_max_x - self.letter_min_x + 1;
128
+ if width < word + 2 {
129
+ return None;
130
+ }
131
+ if width >= self.cols {
132
+ return Some(0);
133
+ }
134
+ // Keep a margin of flame on the right of the word, then spend the rest
135
+ // on the trail.
136
+ let right_edge = (self.letter_max_x + 1 + 4).min(self.cols);
137
+ Some(right_edge.saturating_sub(width).min(self.letter_min_x))
138
+ }
139
+
140
+ /// Cells the wordmark occupies in a terminal of `width`, or `None` when it
141
+ /// is too narrow to draw it.
142
+ pub(crate) fn size(&self, width: u16) -> Option<(u16, u16)> {
143
+ let start = self.start_col(width)?;
144
+ Some(((self.cols - start).min(width), self.rows))
145
+ }
146
+}
147
+
148
+fn clamp01(v: f32) -> f32 {
149
+ v.clamp(0.0, 1.0)
150
+}
151
+
152
+fn ease_out(v: f32) -> f32 {
153
+ 1.0 - (1.0 - v).powi(3)
154
+}
155
+
156
+/// Cheap value noise: a sin-hash of the cell and time, in 0..1.
157
+fn noise(x: u16, y: u16, t_ms: f32) -> f32 {
158
+ let s = (x as f32 * 12.9898 + y as f32 * 78.233 + t_ms * 0.009).sin() * 43758.547;
159
+ s - s.floor()
160
+}
161
+
162
+fn mix(a: Rgb, b: Rgb, k: f32) -> Rgb {
163
+ let k = clamp01(k);
164
+ [0, 1, 2].map(|i| (a[i] as f32 + (b[i] as f32 - a[i] as f32) * k).round() as u8)
165
+}
166
+
167
+fn ramp(heat: f32) -> Rgb {
168
+ let f = clamp01(heat) * (RAMP.len() - 1) as f32;
169
+ let i = (f.floor() as usize).min(RAMP.len() - 2);
170
+ mix(RAMP[i], RAMP[i + 1], f - i as f32)
171
+}
172
+
173
+/// Whether the terminal can show the ember ramp as 24-bit colour.
174
+pub(crate) fn truecolor_supported() -> bool {
175
+ std::env::var("COLORTERM")
176
+ .map(|v| v.contains("truecolor") || v.contains("24bit"))
177
+ .unwrap_or(false)
178
+}
179
+
180
+/// An `Rgb` as a ratatui colour: exact on a truecolor terminal, the nearest
181
+/// cell of the 256-colour cube otherwise.
182
+pub(crate) fn color(rgb: Rgb, truecolor: bool) -> Color {
183
+ if truecolor {
184
+ return Color::Rgb(rgb[0], rgb[1], rgb[2]);
185
+ }
186
+ let level = |v: u8| ((v as u16 * 5 + 127) / 255) as u8;
187
+ Color::Indexed(16 + 36 * level(rgb[0]) + 6 * level(rgb[1]) + level(rgb[2]))
188
+}
189
+
190
+/// `rgb` faded toward the canvas by `1 - k`, as a ratatui colour.
191
+pub(crate) fn fade(rgb: Rgb, k: f32, truecolor: bool) -> Color {
192
+ color(mix(BG, rgb, k), truecolor)
193
+}
194
+
195
+/// One frame of the wordmark. `t` is time since the splash started and `exit`
196
+/// the exit progress, 0 to 1. `still` freezes the flicker for the static frame
197
+/// used under reduced motion.
198
+pub(crate) struct Wordmark<'a> {
199
+ pub banner: &'a Banner,
200
+ pub t: Duration,
201
+ pub exit: f32,
202
+ pub still: bool,
203
+ pub truecolor: bool,
204
+}
205
+
206
+impl Widget for Wordmark<'_> {
207
+ fn render(self, area: Rect, buf: &mut Buffer) {
208
+ let b = self.banner;
209
+ let Some(start) = b.start_col(area.width) else {
210
+ return;
211
+ };
212
+ let visible = (b.cols - start).min(area.width);
213
+ let ox = area.x + (area.width - visible) / 2;
214
+ let t = self.t.as_secs_f32() * 1000.0;
215
+ let e = clamp01(self.exit);
216
+
217
+ // 1. Grid: every cell fades in as a faint dot.
218
+ let grid = clamp01(t / 300.0);
219
+ // 2. Ignition: the front sweeps from the right edge to the left.
220
+ let sweep = clamp01((t - 250.0) / 750.0);
221
+ // 3. Word: letters resolve left to right, then settle one cell right.
222
+ let settle = (1.0 - ease_out(clamp01((t - 600.0) / 500.0))).round() as i32;
223
+ // 4. Hold: the flame keeps breathing, quieter.
224
+ let breathe = if t > 1100.0 { 0.4 } else { 1.0 };
225
+ let span = (b.letter_max_x - b.letter_min_x).max(1) as f32;
226
+ let mid = (b.letter_min_x + b.letter_max_x) as f32 / 2.0;
227
+
228
+ let mut put = |x: i32, y: u16, glyph: &str, rgb: Rgb| {
229
+ let (min, max) = (ox as i32, (ox + visible) as i32);
230
+ if x < min || x >= max || y >= area.height {
231
+ return;
232
+ }
233
+ if let Some(cell) = buf.cell_mut((x as u16, area.y + y)) {
234
+ cell.set_symbol(glyph)
235
+ .set_style(Style::default().fg(color(rgb, self.truecolor)));
236
+ }
237
+ };
238
+
239
+ for c in &b.cells {
240
+ if c.x < start || c.x - start >= visible {
241
+ continue;
242
+ }
243
+ let gx = ox as i32 + (c.x - start) as i32;
244
+
245
+ if c.letter {
246
+ put(gx, c.y, "\u{b7}", mix(BG, RAMP[0], 0.9 * grid * (1.0 - e)));
247
+ let reveal =
248
+ clamp01((t - (600.0 + (c.x - b.letter_min_x) as f32 / span * 350.0)) / 150.0);
249
+ if reveal > 0.0 {
250
+ put(
251
+ gx - settle,
252
+ c.y,
253
+ "\u{2588}",
254
+ mix(BG, INK, reveal * (1.0 - e)),
255
+ );
256
+ }
257
+ continue;
258
+ }
259
+
260
+ let d = (b.cols - 1 - c.x) as f32 / (b.cols - 1) as f32;
261
+ let lit = clamp01((sweep * 1.25 - d) * 5.0);
262
+ // Flicker is strongest on the trail, weakest beside the word.
263
+ let away = clamp01((b.letter_min_x as f32 - c.x as f32) / 25.0);
264
+ let amp = (0.12 + 0.38 * away) * breathe;
265
+ let trail = if c.x < b.letter_min_x { 0.7 } else { 1.0 };
266
+ let n = if self.still { 0.5 } else { noise(c.x, c.y, t) };
267
+ let heat = clamp01(c.heat * trail * lit + (n - 0.5) * 2.0 * amp * lit);
268
+
269
+ if lit <= 0.0 {
270
+ put(gx, c.y, "\u{b7}", mix(BG, RAMP[0], 0.9 * grid * (1.0 - e)));
271
+ continue;
272
+ }
273
+ // Exit: the flame contracts toward the word and dims.
274
+ let shift = ((mid - c.x as f32) * 0.3 * ease_out(e)).round() as i32;
275
+ let glyph = if heat < 0.35 {
276
+ "\u{2591}"
277
+ } else if heat < 0.65 {
278
+ "\u{2592}"
279
+ } else {
280
+ "\u{2593}"
281
+ };
282
+ put(
283
+ gx + shift,
284
+ c.y,
285
+ glyph,
286
+ mix(BG, ramp(heat), lit * (0.55 + 0.45 * heat) * (1.0 - e)),
287
+ );
288
+ }
289
+ }
290
+}
291
+
292
+/// Where the splash is in its life.
293
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
294
+pub(crate) enum Phase {
295
+ Running,
296
+ Done,
297
+}
298
+
299
+/// The splash's clock: when it started, whether it has begun to leave, and
300
+/// whether the user asked it to go.
301
+pub(crate) struct Splash {
302
+ start: Instant,
303
+ exit_start: Option<Instant>,
304
+ skipped: bool,
305
+ reduced: bool,
306
+ pub(crate) banner: Banner,
307
+ pub(crate) truecolor: bool,
308
+}
309
+
310
+impl Splash {
311
+ pub(crate) fn new(now: Instant) -> Self {
312
+ Self {
313
+ start: now,
314
+ exit_start: None,
315
+ skipped: false,
316
+ reduced: reduced_motion(),
317
+ banner: Banner::parse(),
318
+ truecolor: truecolor_supported(),
319
+ }
320
+ }
321
+
322
+ /// A key press: leave now, whether or not the checks are in.
323
+ pub(crate) fn skip(&mut self) {
324
+ self.skipped = true;
325
+ }
326
+
327
+ pub(crate) fn reduced(&self) -> bool {
328
+ self.reduced
329
+ }
330
+
331
+ pub(crate) fn elapsed(&self, now: Instant) -> Duration {
332
+ now.saturating_duration_since(self.start)
333
+ }
334
+
335
+ /// Exit progress, 0 while running and 1 once gone.
336
+ pub(crate) fn exit_progress(&self, now: Instant) -> f32 {
337
+ match self.exit_start {
338
+ None => 0.0,
339
+ Some(_) if self.reduced => 1.0,
340
+ Some(at) => {
341
+ clamp01(now.saturating_duration_since(at).as_secs_f32() / EXIT.as_secs_f32())
342
+ }
343
+ }
344
+ }
345
+
346
+ /// Advance the clock. `ready` is true once every startup check has
347
+ /// reported. The splash starts leaving when it is ready and the minimum
348
+ /// time has passed, when the user skips, or at the hard cap.
349
+ pub(crate) fn update(&mut self, now: Instant, ready: bool) -> Phase {
350
+ let elapsed = self.elapsed(now);
351
+ let min = if self.reduced {
352
+ Duration::ZERO
353
+ } else {
354
+ MIN_SHOW
355
+ };
356
+ if self.exit_start.is_none()
357
+ && (self.skipped || (ready && elapsed >= min) || elapsed >= HARD_CAP)
358
+ {
359
+ self.exit_start = Some(now);
360
+ }
361
+ if self.exit_progress(now) >= 1.0 {
362
+ Phase::Done
363
+ } else {
364
+ Phase::Running
365
+ }
366
+ }
367
+}
368
+
369
+/// `SIGIT_REDUCE_MOTION` set to anything but `0` or empty: draw the finished
370
+/// frame and leave as soon as the checks are in.
371
+fn reduced_motion() -> bool {
372
+ std::env::var("SIGIT_REDUCE_MOTION").is_ok_and(|v| !v.is_empty() && v != "0")
373
+}
374
+
375
+#[cfg(test)]
376
+mod tests {
377
+ use super::*;
378
+
379
+ fn frame(banner: &Banner, width: u16, ms: u64, exit: f32) -> Buffer {
380
+ let area = Rect::new(0, 0, width, banner.rows);
381
+ let mut buf = Buffer::empty(area);
382
+ Wordmark {
383
+ banner,
384
+ t: Duration::from_millis(ms),
385
+ exit,
386
+ still: false,
387
+ truecolor: true,
388
+ }
389
+ .render(area, &mut buf);
390
+ buf
391
+ }
392
+
393
+ fn lit(buf: &Buffer, x0: u16, x1: u16) -> usize {
394
+ (0..buf.area.height)
395
+ .flat_map(|y| (x0..x1).map(move |x| (x, y)))
396
+ .filter(|&(x, y)| matches!(buf[(x, y)].symbol(), "\u{2591}" | "\u{2592}" | "\u{2593}"))
397
+ .count()
398
+ }
399
+
400
+ #[test]
401
+ fn banner_is_the_documented_grid() {
402
+ let b = Banner::parse();
403
+ assert_eq!((b.cols, b.rows), (92, 13));
404
+ assert_eq!((b.letter_min_x, b.letter_max_x), (31, 76));
405
+ assert!(
406
+ b.cells
407
+ .iter()
408
+ .filter(|c| c.letter)
409
+ .all(|c| (2..=11).contains(&c.y))
410
+ );
411
+ }
412
+
413
+ #[test]
414
+ fn ignition_runs_from_right_to_left() {
415
+ let b = Banner::parse();
416
+ let buf = frame(&b, 92, 500, 0.0);
417
+ assert!(lit(&buf, 66, 92) > lit(&buf, 0, 26) * 3);
418
+ }
419
+
420
+ #[test]
421
+ fn letters_are_ink_once_revealed_and_absent_before() {
422
+ let b = Banner::parse();
423
+ let ink = |buf: &Buffer| {
424
+ (0..92)
425
+ .flat_map(|x| (0..13).map(move |y| (x, y)))
426
+ .filter(|&(x, y)| buf[(x, y)].symbol() == "\u{2588}")
427
+ .count()
428
+ };
429
+ assert_eq!(ink(&frame(&b, 92, 400, 0.0)), 0);
430
+ let letters = b.cells.iter().filter(|c| c.letter).count();
431
+ let settled = ink(&frame(&b, 92, 1600, 0.0));
432
+ assert!(settled >= letters - 13, "{settled} of {letters}");
433
+ }
434
+
435
+ #[test]
436
+ fn exit_removes_everything() {
437
+ let b = Banner::parse();
438
+ let buf = frame(&b, 92, 1600, 1.0);
439
+ let bg = color(BG, true);
440
+ for y in 0..13 {
441
+ for x in 0..92 {
442
+ let c = &buf[(x, y)];
443
+ if c.symbol() != " " {
444
+ assert_eq!(c.fg, bg, "({x},{y}) {:?}", c.symbol());
445
+ }
446
+ }
447
+ }
448
+ }
449
+
450
+ #[test]
451
+ fn narrow_terminals_crop_the_trail_and_tiny_ones_drop_the_art() {
452
+ let b = Banner::parse();
453
+ assert_eq!(b.start_col(92), Some(0));
454
+ assert_eq!(b.start_col(120), Some(0));
455
+ let start = b.start_col(80).unwrap();
456
+ assert!(start > 0 && start <= b.letter_min_x);
457
+ // The word still fits.
458
+ assert!(b.letter_max_x - start < 80);
459
+ assert_eq!(b.size(50), Some((50, 13)));
460
+ assert_eq!(b.start_col(40), None);
461
+ }
462
+
463
+ #[test]
464
+ fn frames_do_not_write_outside_the_area() {
465
+ let b = Banner::parse();
466
+ for width in [48, 60, 80, 92, 140] {
467
+ let area = Rect::new(3, 2, width, 13);
468
+ let mut buf = Buffer::empty(Rect::new(0, 0, width + 10, 20));
469
+ Wordmark {
470
+ banner: &b,
471
+ t: Duration::from_millis(1500),
472
+ exit: 0.0,
473
+ still: false,
474
+ truecolor: false,
475
+ }
476
+ .render(area, &mut buf);
477
+ for y in 0..20 {
478
+ for x in 0..width + 10 {
479
+ if !(area.x..area.x + area.width).contains(&x)
480
+ || !(area.y..area.y + 13).contains(&y)
481
+ {
482
+ assert_eq!(buf[(x, y)].symbol(), " ", "({x},{y})");
483
+ }
484
+ }
485
+ }
486
+ }
487
+ }
488
+
489
+ fn splash(reduced: bool) -> (Splash, Instant) {
490
+ let t0 = Instant::now();
491
+ let mut s = Splash::new(t0);
492
+ s.reduced = reduced;
493
+ (s, t0)
494
+ }
495
+
496
+ #[test]
497
+ fn stays_for_the_minimum_even_when_ready() {
498
+ let (mut s, t0) = splash(false);
499
+ assert_eq!(
500
+ s.update(t0 + Duration::from_millis(1000), true),
501
+ Phase::Running
502
+ );
503
+ assert_eq!(s.exit_progress(t0 + Duration::from_millis(1000)), 0.0);
504
+ let at = t0 + MIN_SHOW;
505
+ assert_eq!(s.update(at, true), Phase::Running);
506
+ assert_eq!(s.update(at + EXIT, true), Phase::Done);
507
+ }
508
+
509
+ #[test]
510
+ fn waits_for_checks_then_gives_up_at_the_cap() {
511
+ let (mut s, t0) = splash(false);
512
+ assert_eq!(
513
+ s.update(t0 + Duration::from_millis(3900), false),
514
+ Phase::Running
515
+ );
516
+ assert_eq!(s.exit_progress(t0 + Duration::from_millis(3900)), 0.0);
517
+ let at = t0 + HARD_CAP;
518
+ s.update(at, false);
519
+ assert_eq!(s.update(at + EXIT, false), Phase::Done);
520
+ }
521
+
522
+ #[test]
523
+ fn a_key_press_leaves_without_waiting() {
524
+ let (mut s, t0) = splash(false);
525
+ s.skip();
526
+ s.update(t0 + Duration::from_millis(100), false);
527
+ assert_eq!(
528
+ s.update(t0 + Duration::from_millis(450), false),
529
+ Phase::Done
530
+ );
531
+ }
532
+
533
+ #[test]
534
+ fn reduced_motion_leaves_as_soon_as_the_checks_are_in() {
535
+ let (mut s, t0) = splash(true);
536
+ assert_eq!(
537
+ s.update(t0 + Duration::from_millis(10), false),
538
+ Phase::Running
539
+ );
540
+ assert_eq!(s.update(t0 + Duration::from_millis(20), true), Phase::Done);
541
+ }
542
+
543
+ #[test]
544
+ fn colour_falls_back_to_the_256_cube() {
545
+ assert_eq!(
546
+ color([0xfe, 0x6d, 0x36], true),
547
+ Color::Rgb(0xfe, 0x6d, 0x36)
548
+ );
549
+ assert_eq!(color([0, 0, 0], false), Color::Indexed(16));
550
+ assert_eq!(color([255, 255, 255], false), Color::Indexed(231));
551
+ }
552
+}