| 1 | //! Live caller-owned motion: `cargo run --example motion`. |
| 2 | //! |
| 3 | //! Space finishes/restarts the demonstration, v switches working/verification, |
| 4 | //! r replays, p selects a terminal profile, m changes motion, q or Escape exits. |
| 5 | //! `--frames DIR [--profile NAME]` exports 128 deterministic actual-buffer SVGs. |
| 6 | //! Only the host reads the clock and waits. Completion comes from a host event; |
| 7 | //! the exported specimen has an explicitly authored completion at 4.2 seconds. |
| 8 | |
| 9 | use std::{ |
| 10 | io, |
| 11 | path::{Path, PathBuf}, |
| 12 | time::{Duration, Instant}, |
| 13 | }; |
| 14 | |
| 15 | use codewhale_ratatui::{ |
| 16 | Caps, FrameBudget, KeyHint, KeyHints, MotionDemo, MotionDemoWords, MotionMode, MotionPolicy, |
| 17 | MotionSet, Paint, PaneHeader, Receipt, ReceiptValue, Role, Spinner, State, Theme, |
| 18 | VerificationSpinner, WorkspaceFrame, spin, |
| 19 | testing::{self, Profile}, |
| 20 | text, |
| 21 | }; |
| 22 | use crossterm::{ |
| 23 | event::{self, Event, KeyCode, KeyEventKind}, |
| 24 | execute, |
| 25 | terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, |
| 26 | }; |
| 27 | use ratatui::{ |
| 28 | Terminal, |
| 29 | backend::CrosstermBackend, |
| 30 | buffer::Buffer, |
| 31 | layout::Rect, |
| 32 | text::{Line, Span}, |
| 33 | }; |
| 34 | |
| 35 | const VERIFY_AT: Duration = Duration::from_millis(2_200); |
| 36 | const FINISH_AT: Duration = Duration::from_millis(4_200); |
| 37 | const FRAME_MS: u64 = 50; |
| 38 | const FRAME_COUNT: u64 = 128; |
| 39 | |
| 40 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 41 | enum Stage { |
| 42 | Working, |
| 43 | Verifying, |
| 44 | Done, |
| 45 | } |
| 46 | |
| 47 | impl Stage { |
| 48 | fn word(self) -> &'static str { |
| 49 | match self { |
| 50 | Self::Working => "Working", |
| 51 | Self::Verifying => "Verifying", |
| 52 | Self::Done => "Done", |
| 53 | } |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | /// Display state of this specimen, never a provider/session inference. |
| 58 | struct Demo { |
| 59 | stage: Stage, |
| 60 | run_started: Instant, |
| 61 | phase_started: Instant, |
| 62 | finished_elapsed: Duration, |
| 63 | motions: MotionSet, |
| 64 | } |
| 65 | |
| 66 | impl Demo { |
| 67 | fn new(now: Instant) -> Self { |
| 68 | Self { |
| 69 | stage: Stage::Working, |
| 70 | run_started: now, |
| 71 | phase_started: now, |
| 72 | finished_elapsed: Duration::ZERO, |
| 73 | motions: MotionSet::new(), |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | fn phase_elapsed(&self, now: Instant) -> Duration { |
| 78 | now.saturating_duration_since(self.phase_started) |
| 79 | } |
| 80 | |
| 81 | fn finish(&mut self, now: Instant, policy: MotionPolicy) { |
| 82 | if self.stage == Stage::Done { |
| 83 | return; |
| 84 | } |
| 85 | self.finished_elapsed = now.saturating_duration_since(self.run_started); |
| 86 | self.stage = Stage::Done; |
| 87 | MotionDemo::start(&mut self.motions, true, now, policy); |
| 88 | } |
| 89 | |
| 90 | fn restart(&mut self, now: Instant, policy: MotionPolicy) { |
| 91 | self.stage = Stage::Working; |
| 92 | self.run_started = now; |
| 93 | self.phase_started = now; |
| 94 | self.finished_elapsed = Duration::ZERO; |
| 95 | // Retarget the existing steps: a mid-flight replay preserves spatial |
| 96 | // continuity rather than replacing the set with an unrelated frame. |
| 97 | MotionDemo::start(&mut self.motions, false, now, policy); |
| 98 | } |
| 99 | |
| 100 | fn toggle_verification(&mut self, now: Instant) { |
| 101 | self.stage = match self.stage { |
| 102 | Stage::Working => Stage::Verifying, |
| 103 | Stage::Verifying => Stage::Working, |
| 104 | Stage::Done => return, |
| 105 | }; |
| 106 | self.phase_started = now; |
| 107 | } |
| 108 | |
| 109 | fn next_frame_in(&self, now: Instant, theme: &Theme, mode: MotionMode) -> Option<Duration> { |
| 110 | let mut budget = FrameBudget::new(); |
| 111 | if self.stage != Stage::Done && budget.claim_spinner() { |
| 112 | let elapsed = self.phase_elapsed(now); |
| 113 | budget.request(match self.stage { |
| 114 | Stage::Working => spin::next_frame_in(elapsed, mode), |
| 115 | Stage::Verifying => VerificationSpinner::next_frame_in(elapsed, mode), |
| 116 | Stage::Done => None, |
| 117 | }); |
| 118 | } |
| 119 | budget.request_at( |
| 120 | now, |
| 121 | self.motions |
| 122 | .next_frame_at(now, MotionPolicy::new(mode, theme)), |
| 123 | ); |
| 124 | budget.next_frame_in() |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | fn mode_word(mode: MotionMode) -> &'static str { |
| 129 | match mode { |
| 130 | MotionMode::Full => "Full motion", |
| 131 | MotionMode::Reduced => "Reduced motion", |
| 132 | MotionMode::Still => "Still", |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | fn row(area: Rect, buf: &mut Buffer, line: &Line<'_>) { |
| 137 | let area = area.intersection(buf.area); |
| 138 | if !area.is_empty() { |
| 139 | buf.set_line(area.x, area.y, line, area.width); |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | fn at(area: Rect, offset: u16, height: u16) -> Rect { |
| 144 | Rect::new( |
| 145 | area.x, |
| 146 | area.y.saturating_add(offset.min(area.height)), |
| 147 | area.width, |
| 148 | height.min(area.height.saturating_sub(offset)), |
| 149 | ) |
| 150 | } |
| 151 | |
| 152 | fn copy_paint(component: &impl Paint, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 153 | let area = area.intersection(buf.area); |
| 154 | if area.is_empty() { |
| 155 | return; |
| 156 | } |
| 157 | // The receipt and key-hint components can use ordinary terminal widgets |
| 158 | // inside local coordinates, even when the destination reaches u16::MAX. |
| 159 | let mut local = Buffer::empty(Rect::new(0, 0, area.width, area.height)); |
| 160 | for y in 0..area.height { |
| 161 | for x in 0..area.width { |
| 162 | local[(x, y)] = buf[(area.x + x, area.y + y)].clone(); |
| 163 | } |
| 164 | } |
| 165 | component.paint(local.area, &mut local, theme); |
| 166 | for y in 0..area.height { |
| 167 | for x in 0..area.width { |
| 168 | buf[(area.x + x, area.y + y)] = local[(x, y)].clone(); |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | fn caption(value: &str, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 174 | let safe = text::display_safe(value); |
| 175 | row( |
| 176 | area, |
| 177 | buf, |
| 178 | &Line::styled( |
| 179 | text::truncate_words(&safe, usize::from(area.width), theme.ascii()).into_owned(), |
| 180 | theme.fg(Role::Muted), |
| 181 | ), |
| 182 | ); |
| 183 | } |
| 184 | |
| 185 | fn transitions( |
| 186 | area: Rect, |
| 187 | buf: &mut Buffer, |
| 188 | theme: &Theme, |
| 189 | demo: &Demo, |
| 190 | now: Instant, |
| 191 | mode: MotionMode, |
| 192 | ) -> u16 { |
| 193 | let compact = area.width < 64; |
| 194 | let labels = if compact { |
| 195 | ["Ink / 180ms", "Focus / 180ms", "Detail / 340ms"] |
| 196 | } else { |
| 197 | [ |
| 198 | "State ink / 180ms", |
| 199 | "Selection / 180ms", |
| 200 | "Detail reveal / 340ms", |
| 201 | ] |
| 202 | }; |
| 203 | let words = MotionDemoWords { |
| 204 | working: demo.stage.word().into(), |
| 205 | done: "Done".into(), |
| 206 | slide: "Selected".into(), |
| 207 | detail: "Details are ready".into(), |
| 208 | }; |
| 209 | let component = MotionDemo::new( |
| 210 | &demo.motions, |
| 211 | now, |
| 212 | MotionPolicy::new(mode, theme), |
| 213 | demo.stage == Stage::Done, |
| 214 | ) |
| 215 | .with_words(words); |
| 216 | if area.width >= 30 { |
| 217 | let label_width = if compact { 15 } else { 24 }; |
| 218 | for (index, label) in labels.into_iter().enumerate() { |
| 219 | caption(label, at(area, index as u16, 1), buf, theme); |
| 220 | } |
| 221 | component.paint( |
| 222 | Rect::new( |
| 223 | area.x.saturating_add(label_width), |
| 224 | area.y, |
| 225 | area.width.saturating_sub(label_width), |
| 226 | area.height.min(3), |
| 227 | ), |
| 228 | buf, |
| 229 | theme, |
| 230 | ); |
| 231 | 3 |
| 232 | } else { |
| 233 | // Keep labels legible on unusually narrow terminals. The same |
| 234 | // three actual MotionDemo rows are stacked below their own labels. |
| 235 | let mut local = Buffer::empty(Rect::new(0, 0, area.width, 3)); |
| 236 | component.paint(local.area, &mut local, theme); |
| 237 | for (index, label) in labels.into_iter().enumerate() { |
| 238 | let offset = index as u16 * 2; |
| 239 | caption(label, at(area, offset, 1), buf, theme); |
| 240 | let target = at(area, offset + 1, 1); |
| 241 | if !target.is_empty() { |
| 242 | for x in 0..target.width { |
| 243 | buf[(target.x + x, target.y)] = local[(x, index as u16)].clone(); |
| 244 | } |
| 245 | } |
| 246 | } |
| 247 | 6 |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | fn held_samples(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 252 | let ascii = Theme::new(Caps { |
| 253 | ascii: true, |
| 254 | ..theme.caps() |
| 255 | }) |
| 256 | .ground(theme.ground_kind()); |
| 257 | let specimens = [ |
| 258 | ( |
| 259 | "ASCII / captured", |
| 260 | "800ms specimen", |
| 261 | // This is one frozen frame, not a second live working marker. |
| 262 | Spinner::new("Working", Duration::from_millis(800), MotionMode::Full).spans(&ascii), |
| 263 | ), |
| 264 | ( |
| 265 | "Reduced motion", |
| 266 | "Static marker", |
| 267 | Spinner::new("Working", Duration::ZERO, MotionMode::Reduced).spans(theme), |
| 268 | ), |
| 269 | ( |
| 270 | "Still", |
| 271 | "Static marker", |
| 272 | Spinner::new("Working", Duration::ZERO, MotionMode::Still).spans(theme), |
| 273 | ), |
| 274 | ]; |
| 275 | if area.width >= 72 { |
| 276 | let width = area.width.saturating_sub(4) / 3; |
| 277 | for (index, (title, note, spans)) in specimens.into_iter().enumerate() { |
| 278 | let column = Rect::new( |
| 279 | area.x.saturating_add(index as u16 * (width + 2)), |
| 280 | area.y, |
| 281 | width, |
| 282 | area.height.min(3), |
| 283 | ); |
| 284 | PaneHeader::new(title) |
| 285 | .divider(false) |
| 286 | .paint(at(column, 0, 1), buf, theme); |
| 287 | row(at(column, 1, 1), buf, &Line::from(spans)); |
| 288 | caption(note, at(column, 2, 1), buf, theme); |
| 289 | } |
| 290 | } else { |
| 291 | for (index, (title, _, mut spans)) in specimens.into_iter().enumerate() { |
| 292 | let title = if index == 0 { "ASCII / 800ms" } else { title }; |
| 293 | let mut line = vec![ |
| 294 | Span::styled(title, theme.fg(Role::Muted)), |
| 295 | Span::raw(" ".repeat(16usize.saturating_sub(text::width(title)))), |
| 296 | ]; |
| 297 | line.append(&mut spans); |
| 298 | row(at(area, index as u16, 1), buf, &Line::from(line)); |
| 299 | } |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | fn paint(area: Rect, buf: &mut Buffer, theme: &Theme, demo: &Demo, now: Instant, mode: MotionMode) { |
| 304 | let area = area.intersection(buf.area); |
| 305 | if area.is_empty() { |
| 306 | return; |
| 307 | } |
| 308 | let frame = WorkspaceFrame::new("Motion") |
| 309 | .mode("Demonstration") |
| 310 | .side_width(0) |
| 311 | .footer("Demonstration / caller-owned time and state / q or Esc closes"); |
| 312 | frame.paint(area, buf, theme); |
| 313 | let main = frame.areas(area).main; |
| 314 | PaneHeader::new("Motion in Codewhale") |
| 315 | .meta(mode_word(mode)) |
| 316 | .divider(false) |
| 317 | .paint(at(main, 0, 1), buf, theme); |
| 318 | caption( |
| 319 | &format!("{} / caller-owned time and state", mode_word(mode)), |
| 320 | at(main, 1, 1), |
| 321 | buf, |
| 322 | theme, |
| 323 | ); |
| 324 | PaneHeader::new("Work / verification / receipt") |
| 325 | .meta(demo.stage.word()) |
| 326 | .divider(false) |
| 327 | .paint(at(main, 3, 1), buf, theme); |
| 328 | let elapsed = demo.phase_elapsed(now); |
| 329 | match demo.stage { |
| 330 | Stage::Working => row( |
| 331 | at(main, 4, 1), |
| 332 | buf, |
| 333 | &Line::from(Spinner::new("Working", elapsed, mode).spans(theme)), |
| 334 | ), |
| 335 | Stage::Verifying => row( |
| 336 | at(main, 4, 1), |
| 337 | buf, |
| 338 | &Line::from(VerificationSpinner::new("Verifying", elapsed, mode).spans(theme)), |
| 339 | ), |
| 340 | Stage::Done => copy_paint( |
| 341 | &Receipt::new(State::Done, "Motion sample") |
| 342 | .value(ReceiptValue::Duration(Some(demo.finished_elapsed))), |
| 343 | at(main, 4, 1), |
| 344 | buf, |
| 345 | theme, |
| 346 | ), |
| 347 | } |
| 348 | caption( |
| 349 | if demo.stage == Stage::Done { |
| 350 | "A receipt replaces the live marker." |
| 351 | } else { |
| 352 | "Earned after 400ms / 200ms cadence" |
| 353 | }, |
| 354 | at(main, 5, 1), |
| 355 | buf, |
| 356 | theme, |
| 357 | ); |
| 358 | PaneHeader::new("Three state transitions") |
| 359 | .meta("Space finishes") |
| 360 | .divider(false) |
| 361 | .paint(at(main, 7, 1), buf, theme); |
| 362 | let transition_rows = transitions(at(main, 8, 6), buf, theme, demo, now, mode); |
| 363 | let samples_start = 8 + transition_rows + 1; |
| 364 | PaneHeader::new("Held mode specimens").divider(false).paint( |
| 365 | at(main, samples_start, 1), |
| 366 | buf, |
| 367 | theme, |
| 368 | ); |
| 369 | held_samples(at(main, samples_start + 1, 3), buf, theme); |
| 370 | let hints = KeyHints::new(vec![ |
| 371 | KeyHint::new("Space", "toggle"), |
| 372 | KeyHint::new("v", "verify"), |
| 373 | KeyHint::new("r", "replay"), |
| 374 | KeyHint::new("p", "profile"), |
| 375 | KeyHint::new("m", "motion"), |
| 376 | KeyHint::new("q", "close"), |
| 377 | ]); |
| 378 | let hints_start = samples_start + 5; |
| 379 | copy_paint(&hints, at(main, hints_start, 3), buf, theme); |
| 380 | caption( |
| 381 | if demo.next_frame_in(now, theme, mode).is_some() { |
| 382 | "Redraws only while a marker or step moves." |
| 383 | } else { |
| 384 | "Quiet: waiting for input." |
| 385 | }, |
| 386 | at(main, hints_start + 4, 1), |
| 387 | buf, |
| 388 | theme, |
| 389 | ); |
| 390 | } |
| 391 | |
| 392 | fn exported_demo(base: Instant, elapsed: Duration, theme: &Theme) -> Demo { |
| 393 | let mut demo = Demo::new(base); |
| 394 | if elapsed >= VERIFY_AT { |
| 395 | demo.toggle_verification(base + VERIFY_AT); |
| 396 | } |
| 397 | if elapsed >= FINISH_AT { |
| 398 | demo.finish(base + FINISH_AT, MotionPolicy::new(MotionMode::Full, theme)); |
| 399 | } |
| 400 | demo |
| 401 | } |
| 402 | |
| 403 | fn export_frames(dir: &Path, profile: Profile) -> io::Result<()> { |
| 404 | std::fs::create_dir_all(dir)?; |
| 405 | let theme = profile.theme(); |
| 406 | let base = Instant::now(); |
| 407 | let mut manifest = Vec::new(); |
| 408 | for index in 0..FRAME_COUNT { |
| 409 | let elapsed_ms = index * FRAME_MS; |
| 410 | let elapsed = Duration::from_millis(elapsed_ms); |
| 411 | let demo = exported_demo(base, elapsed, &theme); |
| 412 | let buf = testing::render(96, 30, |area, buf| { |
| 413 | paint(area, buf, &theme, &demo, base + elapsed, MotionMode::Full); |
| 414 | }); |
| 415 | let file = format!("frame-{index:03}.svg"); |
| 416 | std::fs::write(dir.join(&file), testing::svg(&buf, &theme))?; |
| 417 | manifest.push(serde_json::json!({ |
| 418 | "file":file, "elapsed_ms":elapsed_ms, "profile":profile.name(), "width":96, "height":30, |
| 419 | })); |
| 420 | } |
| 421 | std::fs::write( |
| 422 | dir.join("manifest.json"), |
| 423 | serde_json::to_vec_pretty(&manifest)?, |
| 424 | )?; |
| 425 | Ok(()) |
| 426 | } |
| 427 | |
| 428 | fn export_options(args: &[String]) -> io::Result<Option<(PathBuf, Profile)>> { |
| 429 | if args.is_empty() { |
| 430 | return Ok(None); |
| 431 | } |
| 432 | let usage = || { |
| 433 | io::Error::new( |
| 434 | io::ErrorKind::InvalidInput, |
| 435 | "usage: motion [--frames DIR [--profile NAME]]", |
| 436 | ) |
| 437 | }; |
| 438 | if args.first().map(String::as_str) != Some("--frames") || !matches!(args.len(), 2 | 4) { |
| 439 | return Err(usage()); |
| 440 | } |
| 441 | let profile = if args.len() == 4 { |
| 442 | if args[2] != "--profile" { |
| 443 | return Err(usage()); |
| 444 | } |
| 445 | Profile::from_name(&args[3]).ok_or_else(usage)? |
| 446 | } else { |
| 447 | Profile::DarkTrue |
| 448 | }; |
| 449 | Ok(Some((PathBuf::from(&args[1]), profile))) |
| 450 | } |
| 451 | |
| 452 | struct Restore; |
| 453 | impl Drop for Restore { |
| 454 | fn drop(&mut self) { |
| 455 | let _ = disable_raw_mode(); |
| 456 | let _ = execute!(io::stdout(), LeaveAlternateScreen); |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | fn main() -> io::Result<()> { |
| 461 | let args: Vec<_> = std::env::args().skip(1).collect(); |
| 462 | if let Some((dir, profile)) = export_options(&args)? { |
| 463 | return export_frames(&dir, profile); |
| 464 | } |
| 465 | enable_raw_mode()?; |
| 466 | let _restore = Restore; |
| 467 | execute!(io::stdout(), EnterAlternateScreen)?; |
| 468 | let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; |
| 469 | let mut demo = Demo::new(Instant::now()); |
| 470 | let mut profile = 0; |
| 471 | let mut mode = MotionMode::Full; |
| 472 | loop { |
| 473 | let now = Instant::now(); |
| 474 | let theme = Profile::ALL[profile].theme(); |
| 475 | terminal.draw(|frame| paint(frame.area(), frame.buffer_mut(), &theme, &demo, now, mode))?; |
| 476 | if let Some(after) = demo.next_frame_in(now, &theme, mode) |
| 477 | && !event::poll(after.saturating_sub(now.elapsed()))? |
| 478 | { |
| 479 | continue; |
| 480 | } |
| 481 | // No deadline: read blocks until input, including while a reduced |
| 482 | // motion specimen is active and after the completed steps settle. |
| 483 | if let Event::Key(key) = event::read()? |
| 484 | && key.kind == KeyEventKind::Press |
| 485 | { |
| 486 | let now = Instant::now(); |
| 487 | let policy = MotionPolicy::new(mode, &theme); |
| 488 | match key.code { |
| 489 | KeyCode::Char('q') | KeyCode::Esc => break, |
| 490 | KeyCode::Char(' ') => { |
| 491 | if demo.stage == Stage::Done { |
| 492 | demo.restart(now, policy); |
| 493 | } else { |
| 494 | demo.finish(now, policy); |
| 495 | } |
| 496 | } |
| 497 | KeyCode::Char('v') => demo.toggle_verification(now), |
| 498 | KeyCode::Char('r') => demo.restart(now, policy), |
| 499 | KeyCode::Char('p') => { |
| 500 | profile = (profile + 1) % Profile::ALL.len(); |
| 501 | demo.motions.settle_all(); |
| 502 | } |
| 503 | KeyCode::Char('m') => { |
| 504 | mode = match mode { |
| 505 | MotionMode::Full => MotionMode::Reduced, |
| 506 | MotionMode::Reduced => MotionMode::Still, |
| 507 | MotionMode::Still => MotionMode::Full, |
| 508 | }; |
| 509 | demo.motions.settle_all(); |
| 510 | } |
| 511 | _ => {} |
| 512 | } |
| 513 | } |
| 514 | } |
| 515 | Ok(()) |
| 516 | } |
| 517 | |
| 518 | #[cfg(test)] |
| 519 | mod tests { |
| 520 | use super::*; |
| 521 | |
| 522 | fn frame( |
| 523 | base: Instant, |
| 524 | elapsed: Duration, |
| 525 | profile: Profile, |
| 526 | width: u16, |
| 527 | height: u16, |
| 528 | ) -> Buffer { |
| 529 | let theme = profile.theme(); |
| 530 | let demo = exported_demo(base, elapsed, &theme); |
| 531 | testing::render(width, height, |area, buf| { |
| 532 | paint(area, buf, &theme, &demo, base + elapsed, MotionMode::Full); |
| 533 | }) |
| 534 | } |
| 535 | |
| 536 | #[test] |
| 537 | fn export_has_fixed_phases_deterministic_frames_and_a_static_completed_hold() { |
| 538 | let base = Instant::now(); |
| 539 | let later_base = base + Duration::from_secs(3_600); |
| 540 | let theme = Profile::DarkTrue.theme(); |
| 541 | for index in 0..FRAME_COUNT { |
| 542 | let elapsed = Duration::from_millis(index * FRAME_MS); |
| 543 | let demo = exported_demo(base, elapsed, &theme); |
| 544 | let stage = if elapsed < VERIFY_AT { |
| 545 | Stage::Working |
| 546 | } else if elapsed < FINISH_AT { |
| 547 | Stage::Verifying |
| 548 | } else { |
| 549 | Stage::Done |
| 550 | }; |
| 551 | assert_eq!(demo.stage, stage); |
| 552 | assert_eq!( |
| 553 | frame(base, elapsed, Profile::DarkTrue, 96, 30), |
| 554 | frame(later_base, elapsed, Profile::DarkTrue, 96, 30), |
| 555 | "frame {index} must depend on elapsed time, not the wall clock", |
| 556 | ); |
| 557 | } |
| 558 | let verifying = exported_demo(base, VERIFY_AT, &theme); |
| 559 | assert_eq!(verifying.phase_elapsed(base + VERIFY_AT), Duration::ZERO); |
| 560 | let finished = exported_demo(base, FINISH_AT, &theme); |
| 561 | assert_eq!(finished.finished_elapsed, FINISH_AT); |
| 562 | assert!( |
| 563 | testing::text(&frame( |
| 564 | base, |
| 565 | Duration::from_millis(2_600), |
| 566 | Profile::DarkTrue, |
| 567 | 96, |
| 568 | 30 |
| 569 | )) |
| 570 | .contains("Verifying") |
| 571 | ); |
| 572 | assert_eq!( |
| 573 | frame( |
| 574 | base, |
| 575 | Duration::from_millis(4_550), |
| 576 | Profile::DarkTrue, |
| 577 | 96, |
| 578 | 30 |
| 579 | ), |
| 580 | frame( |
| 581 | base, |
| 582 | Duration::from_millis(6_350), |
| 583 | Profile::DarkTrue, |
| 584 | 96, |
| 585 | 30 |
| 586 | ), |
| 587 | "the final hold cannot keep a spinner, time readout or transition moving", |
| 588 | ); |
| 589 | } |
| 590 | |
| 591 | #[test] |
| 592 | fn scheduler_uses_earned_cadence_then_stops_for_quiet_modes_and_settled_completion() { |
| 593 | let base = Instant::now(); |
| 594 | let theme = Profile::DarkTrue.theme(); |
| 595 | let policy = MotionPolicy::new(MotionMode::Full, &theme); |
| 596 | let mut demo = Demo::new(base); |
| 597 | assert_eq!( |
| 598 | demo.next_frame_in(base, &theme, MotionMode::Full), |
| 599 | Some(spin::EARN_DELAY) |
| 600 | ); |
| 601 | assert_eq!( |
| 602 | demo.next_frame_in(base + spin::EARN_DELAY, &theme, MotionMode::Full), |
| 603 | Some(spin::FRAME_INTERVAL) |
| 604 | ); |
| 605 | for mode in [MotionMode::Reduced, MotionMode::Still] { |
| 606 | assert_eq!(demo.next_frame_in(base, &theme, mode), None); |
| 607 | } |
| 608 | demo.toggle_verification(base + VERIFY_AT); |
| 609 | assert_eq!( |
| 610 | demo.next_frame_in(base + VERIFY_AT, &theme, MotionMode::Full), |
| 611 | VerificationSpinner::next_frame_in(Duration::ZERO, MotionMode::Full) |
| 612 | ); |
| 613 | demo.finish(base + FINISH_AT, policy); |
| 614 | assert!( |
| 615 | demo.next_frame_in(base + FINISH_AT, &theme, MotionMode::Full) |
| 616 | .is_some() |
| 617 | ); |
| 618 | assert_eq!( |
| 619 | demo.next_frame_in( |
| 620 | base + FINISH_AT + Duration::from_millis(340), |
| 621 | &theme, |
| 622 | MotionMode::Full |
| 623 | ), |
| 624 | None |
| 625 | ); |
| 626 | let mut reduced = Demo::new(base); |
| 627 | reduced.finish( |
| 628 | base + FINISH_AT, |
| 629 | MotionPolicy::new(MotionMode::Reduced, &theme), |
| 630 | ); |
| 631 | assert!(reduced.motions.is_settled(base + FINISH_AT)); |
| 632 | assert_eq!( |
| 633 | reduced.next_frame_in(base + FINISH_AT, &theme, MotionMode::Reduced), |
| 634 | None |
| 635 | ); |
| 636 | } |
| 637 | |
| 638 | #[test] |
| 639 | fn replay_preserves_step_continuity_and_resets_only_caller_owned_elapsed_time() { |
| 640 | let base = Instant::now(); |
| 641 | let theme = Profile::DarkTrue.theme(); |
| 642 | let policy = MotionPolicy::new(MotionMode::Full, &theme); |
| 643 | let mut demo = Demo::new(base); |
| 644 | demo.finish(base + FINISH_AT, policy); |
| 645 | let midway = base + FINISH_AT + Duration::from_millis(50); |
| 646 | let before = demo.motions.step("slide").at(midway); |
| 647 | assert!(before > 0.0 && before < 1.0); |
| 648 | demo.restart(midway, policy); |
| 649 | assert_eq!(demo.stage, Stage::Working); |
| 650 | assert_eq!(demo.phase_elapsed(midway), Duration::ZERO); |
| 651 | assert_eq!(demo.finished_elapsed, Duration::ZERO); |
| 652 | assert_eq!(demo.motions.step("slide").at(midway), before); |
| 653 | assert_eq!(demo.motions.step("slide").target(), 0.0); |
| 654 | demo.toggle_verification(midway + Duration::from_millis(100)); |
| 655 | assert_eq!(demo.stage, Stage::Verifying); |
| 656 | assert_eq!( |
| 657 | demo.phase_elapsed(midway + Duration::from_millis(100)), |
| 658 | Duration::ZERO |
| 659 | ); |
| 660 | demo.finish(midway + Duration::from_millis(700), policy); |
| 661 | assert_eq!(demo.finished_elapsed, Duration::from_millis(700)); |
| 662 | let last_step = demo.motions.step("reveal"); |
| 663 | demo.finish(midway + Duration::from_secs(20), policy); |
| 664 | assert_eq!(demo.finished_elapsed, Duration::from_millis(700)); |
| 665 | assert_eq!( |
| 666 | demo.motions.step("reveal"), |
| 667 | last_step, |
| 668 | "same completion never restarts a transition" |
| 669 | ); |
| 670 | } |
| 671 | |
| 672 | #[test] |
| 673 | fn narrow_profiles_keep_state_words_and_partial_tiny_or_maximum_buffers_are_bounded() { |
| 674 | let base = Instant::now(); |
| 675 | for profile in Profile::ALL { |
| 676 | let theme = profile.theme(); |
| 677 | for (elapsed, state) in [(1_000, "Working"), (2_800, "Verifying"), (4_550, "Done")] { |
| 678 | let buf = frame(base, Duration::from_millis(elapsed), profile, 40, 28); |
| 679 | let shown = testing::text(&buf); |
| 680 | assert!( |
| 681 | shown.contains("Motion in Codewhale") && shown.contains(state), |
| 682 | "{shown}" |
| 683 | ); |
| 684 | assert!( |
| 685 | shown.contains("Ink / 180ms") && shown.contains("Detail / 340ms"), |
| 686 | "{shown}" |
| 687 | ); |
| 688 | assert!( |
| 689 | shown.contains("ASCII / 800ms") |
| 690 | && shown.contains("Reduced motion") |
| 691 | && shown.contains("Still"), |
| 692 | "{shown}" |
| 693 | ); |
| 694 | assert!( |
| 695 | shown.contains("v verify") && shown.contains("r replay"), |
| 696 | "{shown}" |
| 697 | ); |
| 698 | let checked = testing::Frame::new("narrow motion", profile, buf); |
| 699 | assert!(checked.violations().is_empty(), "{}", checked.label()); |
| 700 | } |
| 701 | for origin in [(5, 4), (u16::MAX - 82, u16::MAX - 31)] { |
| 702 | for width in [0, 1, 4, 80] { |
| 703 | for height in [0, 1, 4, 30] { |
| 704 | let bounds = Rect::new(origin.0, origin.1, 82, 31); |
| 705 | let requested = Rect::new(origin.0 + 2, origin.1 + 1, width, height); |
| 706 | let clipped = requested.intersection(bounds); |
| 707 | let mut buf = Buffer::empty(bounds); |
| 708 | for cell in &mut buf.content { |
| 709 | cell.set_symbol("z"); |
| 710 | } |
| 711 | let before = buf.clone(); |
| 712 | let elapsed = Duration::from_millis(4_250); |
| 713 | let demo = exported_demo(base, elapsed, &theme); |
| 714 | paint( |
| 715 | requested, |
| 716 | &mut buf, |
| 717 | &theme, |
| 718 | &demo, |
| 719 | base + elapsed, |
| 720 | MotionMode::Full, |
| 721 | ); |
| 722 | for y in bounds.y..bounds.bottom() { |
| 723 | for x in bounds.x..bounds.right() { |
| 724 | if !clipped.contains((x, y).into()) { |
| 725 | assert_eq!( |
| 726 | buf[(x, y)], |
| 727 | before[(x, y)], |
| 728 | "{} {width}x{height}", |
| 729 | profile.name() |
| 730 | ); |
| 731 | } |
| 732 | } |
| 733 | } |
| 734 | } |
| 735 | } |
| 736 | } |
| 737 | } |
| 738 | } |
| 739 | } |
| 740 |