I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

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