1use std::io::{self, IsTerminal, Write};
16use std::time::{Duration, Instant};
17
18use crossterm::{
19 cursor::{Hide, MoveTo, Show},
20 execute, queue,
21 style::{Print, ResetColor},
22 terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
23};
24
25use crate::frame::{self, Paint};
26use crate::{art::Art, easing::Easing, guard, rank::RankMap};
27
28const GLOW_LEVELS: u8 = 8;
31
32pub(crate) const SYNC_BEGIN: &str = "\x1b[?2026h";
37pub(crate) const SYNC_END: &str = "\x1b[?2026l";
38
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum ColorDepth {
50 Mono,
52 Ansi16,
54 #[default]
56 Ansi256,
57 TrueColor,
59}
60
61impl ColorDepth {
62 pub fn detect() -> Self {
69 let var = |k: &str| std::env::var(k).unwrap_or_default().to_ascii_lowercase();
70
71 if std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) {
72 return ColorDepth::Mono;
73 }
74 let term = var("TERM");
75 if term == "dumb" {
76 return ColorDepth::Mono;
77 }
78 let colorterm = var("COLORTERM");
79 if colorterm.contains("truecolor") || colorterm.contains("24bit") {
80 return ColorDepth::TrueColor;
81 }
82 if std::env::var_os("WT_SESSION").is_some() || (cfg!(windows) && term.is_empty()) {
84 return ColorDepth::TrueColor;
85 }
86 if term.contains("256color") {
87 return ColorDepth::Ansi256;
88 }
89 if term.contains("16color") || term == "linux" {
90 return ColorDepth::Ansi16;
91 }
92 ColorDepth::Ansi256
93 }
94
95 #[inline]
97 pub fn is_color(self) -> bool {
98 self != ColorDepth::Mono
99 }
100
101 pub fn quantize(self, (r, g, b): (u8, u8, u8)) -> Option<Fg> {
103 match self {
104 ColorDepth::Mono => None,
105 ColorDepth::TrueColor => Some(Fg::Rgb(r, g, b)),
106 ColorDepth::Ansi256 => Some(Fg::Indexed(ansi256(r, g, b))),
107 ColorDepth::Ansi16 => Some(Fg::Basic(ansi16(r, g, b))),
108 }
109 }
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub enum Fg {
122 Rgb(u8, u8, u8),
124 Indexed(u8),
126 Basic(u8),
128}
129
130pub(crate) const FG_RESET: &str = "\x1b[39m";
133
134impl std::fmt::Display for Fg {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 match *self {
137 Fg::Rgb(r, g, b) => write!(f, "\x1b[38;2;{r};{g};{b}m"),
138 Fg::Indexed(i) => write!(f, "\x1b[38;5;{i}m"),
139 Fg::Basic(i @ 0..=7) => write!(f, "\x1b[{}m", 30 + i as u16),
141 Fg::Basic(i) => write!(f, "\x1b[{}m", 90 + (i.min(15) - 8) as u16),
142 }
143 }
144}
145
146fn ansi256(r: u8, g: u8, b: u8) -> u8 {
150 let (lo, hi) = (r.min(g).min(b) as i32, r.max(g).max(b) as i32);
151 if hi - lo < 12 {
152 let level = ((r as i32 + g as i32 + b as i32) / 3 - 8).clamp(0, 238);
153 return 232 + (level * 23 / 238) as u8;
154 }
155 let step = |v: u8| -> u8 {
158 match v {
159 0..=47 => 0,
160 48..=114 => 1,
161 115..=154 => 2,
162 155..=194 => 3,
163 195..=234 => 4,
164 _ => 5,
165 }
166 };
167 16 + 36 * step(r) + 6 * step(g) + step(b)
168}
169
170fn ansi16(r: u8, g: u8, b: u8) -> u8 {
172 const PALETTE: [(u8, u8, u8); 16] = [
173 (0, 0, 0),
174 (128, 0, 0),
175 (0, 128, 0),
176 (128, 128, 0),
177 (0, 0, 128),
178 (128, 0, 128),
179 (0, 128, 128),
180 (192, 192, 192),
181 (128, 128, 128),
182 (255, 0, 0),
183 (0, 255, 0),
184 (255, 255, 0),
185 (0, 0, 255),
186 (255, 0, 255),
187 (0, 255, 255),
188 (255, 255, 255),
189 ];
190 let dist = |&(pr, pg, pb): &(u8, u8, u8)| {
191 let d = |a: u8, b: u8| (a as i32 - b as i32).pow(2);
192 d(pr, r) + d(pg, g) + d(pb, b)
193 };
194 PALETTE
195 .iter()
196 .enumerate()
197 .min_by_key(|(_, c)| dist(c))
198 .map(|(i, _)| i as u8)
199 .unwrap_or(7)
200}
201
202#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
208pub enum Palette {
209 #[default]
211 Glow,
212 Rainbow,
214}
215
216#[derive(Clone, Copy, Debug)]
218pub struct Style {
219 pub feather: f32,
222 pub body: (u8, u8, u8),
224 pub head: (u8, u8, u8),
226 pub depth: ColorDepth,
229 pub palette: Palette,
231 pub caption: (u8, u8, u8),
233}
234
235impl Default for Style {
236 fn default() -> Self {
238 Style {
239 feather: 0.07,
240 body: (120, 134, 168),
241 head: (255, 226, 138),
242 depth: ColorDepth::detect(),
243 palette: Palette::Glow,
244 caption: (120, 134, 168),
245 }
246 }
247}
248
249impl Style {
250 pub fn rainbow() -> Self {
253 Style {
254 palette: Palette::Rainbow,
255 ..Style::default()
256 }
257 }
258
259 pub fn light() -> Self {
266 Style {
267 body: (72, 84, 112),
268 head: (176, 106, 12),
269 caption: (96, 106, 130),
270 ..Style::default()
271 }
272 }
273
274 pub fn monochrome() -> Self {
276 Style {
277 depth: ColorDepth::Mono,
278 ..Style::default()
279 }
280 }
281
282 #[inline]
284 pub fn is_color(&self) -> bool {
285 self.depth.is_color()
286 }
287}
288
289fn blend(a: (u8, u8, u8), b: (u8, u8, u8), s: f32) -> (u8, u8, u8) {
295 let lerp = |x: u8, y: u8| {
296 (x as f32 + (y as f32 - x as f32) * s)
297 .round()
298 .clamp(0.0, 255.0) as u8
299 };
300 (lerp(a.0, b.0), lerp(a.1, b.1), lerp(a.2, b.2))
301}
302
303#[inline]
306fn settle(style: &Style, progress: f32, rank: f32) -> f32 {
307 if style.feather <= 0.0 {
308 1.0
309 } else {
310 ((progress - rank) / style.feather).clamp(0.0, 1.0)
311 }
312}
313
314pub(crate) fn frontier_rgb(style: &Style, progress: f32, rank: f32) -> (u8, u8, u8) {
317 blend(style.head, style.body, settle(style, progress, rank))
318}
319
320pub(crate) fn cell_rgb(
323 style: &Style,
324 progress: f32,
325 rank: f32,
326 x: u16,
327 y: u16,
328 t: f32,
329) -> (u8, u8, u8) {
330 match style.palette {
331 Palette::Glow => frontier_rgb(style, progress, rank),
332 Palette::Rainbow => rainbow_rgb(x, y, t),
333 }
334}
335
336fn rainbow_rgb(x: u16, y: u16, t: f32) -> (u8, u8, u8) {
338 let hue = (x as f32 * 0.05 + y as f32 * 0.12 + t * 0.4).rem_euclid(1.0);
339 hsl_to_rgb(hue, 0.95, 0.62)
340}
341
342fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
344 let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
345 let hp = h * 6.0;
346 let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
347 let (r, g, b) = match hp as u32 {
348 0 => (c, x, 0.0),
349 1 => (x, c, 0.0),
350 2 => (0.0, c, x),
351 3 => (0.0, x, c),
352 4 => (x, 0.0, c),
353 _ => (c, 0.0, x),
354 };
355 let m = l - c / 2.0;
356 let to = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
357 (to(r), to(g), to(b))
358}
359
360#[derive(Clone, Copy)]
370pub(crate) struct Scene<'a> {
371 pub art: &'a Art,
372 pub ranks: &'a RankMap,
373 pub style: &'a Style,
374}
375
376pub(crate) fn queue_row<W: Write>(
385 out: &mut W,
386 scene: Scene<'_>,
387 progress: f32,
388 t: f32,
389 y: u16,
390 budget: u16,
391) -> io::Result<()> {
392 let Scene { art, ranks, style } = scene;
393 let mut cells: Vec<(u16, Paint)> = Vec::with_capacity(art.width() as usize);
395 for (x, at, paint) in frame::row(art, ranks, progress, y) {
396 if at.saturating_add(paint.cols()) > budget {
397 break;
398 }
399 cells.push((x, paint));
400 }
401 while matches!(cells.last(), Some((_, Paint::Blank { .. }))) {
402 cells.pop();
403 }
404
405 let mut current: Option<Fg> = None;
406 for (x, paint) in cells {
407 match paint {
408 Paint::Blank { cols } => {
409 if current.take().is_some() {
412 write!(out, "{FG_RESET}")?;
413 }
414 for _ in 0..cols {
415 out.write_all(b" ")?;
416 }
417 }
418 Paint::Ink { glyph, .. } => {
419 let color = ranks
420 .rank_at(x, y)
421 .and_then(|r| style.depth.quantize(cell_rgb(style, progress, r, x, y, t)));
422 if color != current {
423 match color {
424 Some(c) => write!(out, "{c}")?,
425 None => write!(out, "{FG_RESET}")?,
426 }
427 current = color;
428 }
429 write!(out, "{glyph}")?;
430 }
431 }
432 }
433 if current.is_some() {
434 write!(out, "{FG_RESET}")?;
435 }
436 Ok(())
437}
438
439#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub(crate) struct Viewport {
442 pub cols: u16,
443 pub rows: u16,
444}
445
446impl Viewport {
447 pub fn detect() -> Self {
449 let (cols, rows) = terminal::size().unwrap_or((80, 24));
450 Viewport {
451 cols: cols.max(1),
452 rows: rows.max(1),
453 }
454 }
455
456 pub fn fit(&self, art: &Art, reserve_rows: u16) -> Fit {
460 let art_w = frame::art_cols(art);
461 let usable_rows = self.rows.saturating_sub(reserve_rows);
462 Fit {
463 ox: self.cols.saturating_sub(art_w) / 2,
464 oy: usable_rows.saturating_sub(art.height()) / 2,
465 cols: art_w.min(self.cols),
466 rows: art.height().min(usable_rows),
467 }
468 }
469}
470
471#[derive(Clone, Copy, Debug, PartialEq, Eq)]
473pub(crate) struct Fit {
474 pub ox: u16,
475 pub oy: u16,
476 pub cols: u16,
477 pub rows: u16,
478}
479
480#[derive(Clone, Copy, PartialEq, Eq)]
486enum CellState {
487 Hidden,
488 Lit(u8),
490}
491
492pub struct Reveal<'a> {
517 art: &'a Art,
518 ranks: &'a RankMap,
519 style: Style,
520 state: Vec<CellState>,
521 out: io::Stdout,
522 viewport: Viewport,
524 fit: Fit,
525 active: bool,
527}
528
529impl<'a> Reveal<'a> {
530 pub fn new(art: &'a Art, ranks: &'a RankMap, style: Style) -> io::Result<Self> {
533 let mut out = io::stdout();
534 let active = out.is_terminal();
535 let viewport = if active {
536 Viewport::detect()
537 } else {
538 Viewport { cols: 80, rows: 24 }
539 };
540 let fit = viewport.fit(art, 0);
541 if active {
542 guard::arm();
543 execute!(out, EnterAlternateScreen, Hide, Clear(ClearType::All))?;
544 guard::set_alt_screen(true);
545 guard::set_cursor_hidden(true);
546 }
547 Ok(Reveal {
548 art,
549 ranks,
550 style,
551 state: vec![CellState::Hidden; art.cell_count()],
552 out,
553 viewport,
554 fit,
555 active,
556 })
557 }
558
559 pub fn render(&mut self, progress: f32) -> io::Result<()> {
561 if !self.active {
562 return Ok(());
563 }
564 let viewport = Viewport::detect();
566 if viewport != self.viewport {
567 self.viewport = viewport;
568 self.fit = viewport.fit(self.art, 0);
569 self.state.fill(CellState::Hidden);
570 execute!(self.out, Clear(ClearType::All))?;
571 }
572 self.paint(progress)
573 }
574
575 fn paint(&mut self, progress: f32) -> io::Result<()> {
578 let (art, ranks, style, fit) = (self.art, self.ranks, &self.style, self.fit);
579 let mut dirty = false;
580
581 for y in 0..fit.rows {
582 for (x, at, cell) in frame::row(art, ranks, progress, y) {
583 if at.saturating_add(cell.cols()) > fit.cols {
584 break;
585 }
586 let idx = art.index(x, y);
587 let target = match cell {
588 Paint::Blank { .. } => CellState::Hidden,
589 Paint::Ink { .. } => {
590 let rank = ranks.rank_at(x, y).unwrap_or(0.0);
591 let level = match style.palette {
592 Palette::Rainbow => GLOW_LEVELS,
595 Palette::Glow => {
596 (settle(style, progress, rank) * GLOW_LEVELS as f32).round() as u8
597 }
598 };
599 CellState::Lit(level)
600 }
601 };
602
603 if self.state[idx] == target {
604 continue;
605 }
606 if !dirty {
607 queue!(self.out, Print(SYNC_BEGIN))?;
608 dirty = true;
609 }
610 queue!(self.out, MoveTo(fit.ox + at, fit.oy + y))?;
611 match (target, cell) {
612 (CellState::Hidden, _) => {
615 for _ in 0..cell.cols() {
616 queue!(self.out, Print(' '))?;
617 }
618 }
619 (CellState::Lit(level), Paint::Ink { glyph, .. }) => {
620 let rgb = match style.palette {
621 Palette::Rainbow => rainbow_rgb(x, y, 0.0),
622 Palette::Glow => {
623 blend(style.head, style.body, level as f32 / GLOW_LEVELS as f32)
624 }
625 };
626 if let Some(c) = style.depth.quantize(rgb) {
627 write!(self.out, "{c}")?;
628 }
629 write!(self.out, "{glyph}")?;
630 }
631 (CellState::Lit(_), Paint::Blank { .. }) => unreachable!(),
632 }
633 self.state[idx] = target;
634 }
635 }
636
637 if dirty {
638 write!(self.out, "{FG_RESET}")?;
639 queue!(self.out, Print(SYNC_END))?;
640 self.out.flush()?;
641 }
642 Ok(())
643 }
644
645 pub fn finish(mut self) -> io::Result<()> {
647 self.restore()?;
648 write!(self.out, "{}", frame::to_string(self.art, self.ranks, 1.0))?;
649 self.out.flush()
650 }
651
652 fn restore(&mut self) -> io::Result<()> {
653 if self.active {
654 self.active = false;
655 execute!(self.out, ResetColor, Show, LeaveAlternateScreen)?;
656 guard::set_alt_screen(false);
657 guard::set_cursor_hidden(false);
658 }
659 Ok(())
660 }
661}
662
663impl Drop for Reveal<'_> {
664 fn drop(&mut self) {
665 let _ = self.restore();
666 }
667}
668
669pub fn animate(
674 art: &Art,
675 ranks: &RankMap,
676 style: Style,
677 duration: Duration,
678 easing: Easing,
679) -> io::Result<()> {
680 if !io::stdout().is_terminal() {
681 print!("{}", frame::to_string(art, ranks, 1.0));
682 return Ok(());
683 }
684
685 let mut reveal = Reveal::new(art, ranks, style)?;
686 let total = duration.as_secs_f32().max(0.001);
687 let frame_time = Duration::from_millis(16); let start = Instant::now();
689
690 for tick in 1u32.. {
691 let t = (start.elapsed().as_secs_f32() / total).min(1.0);
692 reveal.render(easing.apply(t))?;
693 if t >= 1.0 {
694 break;
695 }
696 if let Some(remaining) = (start + frame_time * tick).checked_duration_since(Instant::now())
699 {
700 std::thread::sleep(remaining);
701 }
702 }
703 reveal.finish()
704}
705
706pub use crate::frame::art_cols;
708pub use crate::width::{glyph_cols as display_cols, truncate_to_cols};
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713 use crate::ordering::{Geodesic, Ordering};
714
715 fn row_bytes(
716 art: &Art,
717 ranks: &RankMap,
718 style: &Style,
719 progress: f32,
720 y: u16,
721 budget: u16,
722 ) -> String {
723 let mut buf: Vec<u8> = Vec::new();
724 let scene = Scene { art, ranks, style };
725 queue_row(&mut buf, scene, progress, 0.0, y, budget).unwrap();
726 String::from_utf8(buf).unwrap()
727 }
728
729 #[test]
730 fn monochrome_rows_carry_no_escapes() {
731 let art = Art::parse("####");
732 let ranks = Geodesic::default().rank(&art);
733 let out = row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 80);
734 assert_eq!(out, "####");
735 }
736
737 #[test]
738 fn rows_are_clipped_to_the_budget() {
739 let art = Art::parse("##########");
740 let ranks = Geodesic::default().rank(&art);
741 let out = row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 4);
742 assert_eq!(out, "####");
743 }
744
745 #[cfg(feature = "unicode")]
747 #[test]
748 fn clipping_never_splits_a_wide_glyph() {
749 let art = Art::parse("世界");
750 let ranks = Geodesic::default().rank(&art);
751 assert_eq!(
752 row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 3),
753 "世"
754 );
755 assert_eq!(
756 row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 4),
757 "世界"
758 );
759 }
760
761 #[cfg(feature = "unicode")]
764 #[test]
765 fn hidden_wide_glyphs_hold_their_columns() {
766 use crate::width::str_cols;
767 let art = Art::parse("世a");
768 let mut ranks = RankMap::new(art.width(), art.height());
769 ranks.set(0, 0, 1.0); ranks.set(1, 0, 0.0);
771 let early = row_bytes(&art, &ranks, &Style::monochrome(), 0.5, 0, 80);
772 assert_eq!(early, " a", "hidden wide glyph must reserve two columns");
773 assert_eq!(str_cols(&early), 3);
774 assert_eq!(
775 str_cols(&row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 80)),
776 3
777 );
778 }
779
780 #[test]
781 fn trailing_blanks_are_trimmed() {
782 let art = Art::parse("# #");
783 let mut ranks = RankMap::new(art.width(), art.height());
784 ranks.set(0, 0, 0.0);
785 ranks.set(4, 0, 1.0);
786 assert_eq!(
787 row_bytes(&art, &ranks, &Style::monochrome(), 0.5, 0, 80),
788 "#"
789 );
790 }
791
792 #[test]
793 fn colour_runs_are_coalesced() {
794 let art = Art::parse("####");
795 let ranks = Geodesic::default().rank(&art);
796 let style = Style {
797 feather: 0.0, depth: ColorDepth::TrueColor,
799 ..Style::default()
800 };
801 let out = row_bytes(&art, &ranks, &style, 1.0, 0, 80);
802 assert_eq!(
803 out.matches("\x1b[38;2;").count(),
804 1,
805 "one escape should cover the whole run: {out:?}"
806 );
807 assert!(out.ends_with(FG_RESET), "run must be closed: {out:?}");
808 }
809
810 #[test]
811 fn depth_maps_onto_the_available_palette() {
812 assert_eq!(ColorDepth::Mono.quantize((255, 0, 0)), None);
813 assert_eq!(
814 ColorDepth::TrueColor.quantize((1, 2, 3)),
815 Some(Fg::Rgb(1, 2, 3))
816 );
817 assert_eq!(
818 ColorDepth::Ansi16.quantize((250, 10, 10)),
819 Some(Fg::Basic(9))
820 );
821 assert_eq!(
824 ColorDepth::Ansi256.quantize((0, 0, 0)),
825 Some(Fg::Indexed(232))
826 );
827 assert_eq!(
828 ColorDepth::Ansi256.quantize((255, 255, 255)),
829 Some(Fg::Indexed(255))
830 );
831 assert_eq!(
833 ColorDepth::Ansi256.quantize((255, 0, 0)),
834 Some(Fg::Indexed(16 + 36 * 5))
835 );
836 }
837
838 #[test]
839 fn foreground_escapes_are_well_formed() {
840 assert_eq!(Fg::Rgb(1, 2, 3).to_string(), "\x1b[38;2;1;2;3m");
841 assert_eq!(Fg::Indexed(200).to_string(), "\x1b[38;5;200m");
842 assert_eq!(Fg::Basic(3).to_string(), "\x1b[33m");
843 assert_eq!(Fg::Basic(9).to_string(), "\x1b[91m");
844 assert_eq!(FG_RESET, "\x1b[39m");
845 }
846
847 #[test]
848 fn viewport_fit_clips_oversized_art() {
849 let art = Art::parse(&"##########\n".repeat(10));
850 let viewport = Viewport { cols: 4, rows: 3 };
851 let fit = viewport.fit(&art, 1);
852 assert_eq!(fit.cols, 4, "clipped, not wrapped");
853 assert_eq!(fit.rows, 2, "one row reserved for the caption");
854 assert_eq!((fit.ox, fit.oy), (0, 0));
855 }
856
857 #[test]
858 fn viewport_fit_centres_small_art() {
859 let art = Art::parse("##");
860 let fit = Viewport { cols: 10, rows: 10 }.fit(&art, 0);
861 assert_eq!(fit.ox, 4);
862 assert_eq!(fit.cols, 2);
863 }
864
865 #[test]
866 fn light_style_is_darker_than_the_default() {
867 let sum = |(r, g, b): (u8, u8, u8)| r as u32 + g as u32 + b as u32;
868 assert!(sum(Style::light().body) < sum(Style::default().body));
869 assert!(sum(Style::light().head) < sum(Style::default().head));
870 }
871
872 #[cfg(feature = "unicode")]
873 #[test]
874 fn display_width_counts_wide_glyphs() {
875 use crate::width::glyph_cols;
876 assert_eq!(glyph_cols('a'), 1);
877 assert_eq!(glyph_cols('世'), 2);
878 let art = Art::parse("a世\nbb"); assert_eq!(art_cols(&art), 3);
880 }
881}