I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.
feature/skill-slash-commands
| 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 | } |