1use std::io::{self, IsTerminal, Write};
15use std::time::{Duration, Instant};
16
17use crossterm::{
18 cursor::{Hide, MoveTo, Show},
19 execute, queue,
20 style::{Color, Print, ResetColor, SetForegroundColor},
21 terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
22};
23
24use crate::{art::Art, easing::Easing, rank::RankMap};
25
26const GLOW_LEVELS: u8 = 8;
29
30pub(crate) const SYNC_BEGIN: &str = "\x1b[?2026h";
35pub(crate) const SYNC_END: &str = "\x1b[?2026l";
36
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub enum Palette {
40 #[default]
42 Glow,
43 Rainbow,
45}
46
47#[derive(Clone, Copy, Debug)]
49pub struct Style {
50 pub feather: f32,
53 pub body: (u8, u8, u8),
55 pub head: (u8, u8, u8),
57 pub color: bool,
59 pub palette: Palette,
61}
62
63impl Default for Style {
64 fn default() -> Self {
65 Style {
66 feather: 0.07,
67 body: (120, 134, 168),
68 head: (255, 226, 138),
69 color: std::env::var_os("NO_COLOR").is_none(),
70 palette: Palette::Glow,
71 }
72 }
73}
74
75impl Style {
76 pub fn rainbow() -> Self {
79 Style {
80 palette: Palette::Rainbow,
81 ..Style::default()
82 }
83 }
84}
85
86#[derive(Clone, Copy, PartialEq, Eq)]
88enum CellState {
89 Hidden,
90 Lit(u8),
92}
93
94pub struct Reveal<'a> {
118 art: &'a Art,
119 ranks: &'a RankMap,
120 style: Style,
121 state: Vec<CellState>,
122 out: io::Stdout,
123 origin: (u16, u16),
124 active: bool,
126}
127
128impl<'a> Reveal<'a> {
129 pub fn new(art: &'a Art, ranks: &'a RankMap, style: Style) -> io::Result<Self> {
132 let mut out = io::stdout();
133 let active = out.is_terminal();
134 let origin = if active {
135 let (cols, _) = terminal::size().unwrap_or((art.width(), art.height()));
136 (cols.saturating_sub(art_cols(art)) / 2, 1)
137 } else {
138 (0, 0)
139 };
140 if active {
141 execute!(out, EnterAlternateScreen, Hide, Clear(ClearType::All))?;
142 }
143 Ok(Reveal {
144 art,
145 ranks,
146 style,
147 state: vec![CellState::Hidden; art.cell_count()],
148 out,
149 origin,
150 active,
151 })
152 }
153
154 pub fn render(&mut self, progress: f32) -> io::Result<()> {
156 if !self.active {
157 return Ok(());
158 }
159 paint(
160 &mut self.out,
161 self.art,
162 self.ranks,
163 &self.style,
164 &mut self.state,
165 progress,
166 self.origin,
167 )
168 }
169
170 pub fn finish(mut self) -> io::Result<()> {
172 self.restore()?;
173 write!(
174 self.out,
175 "{}",
176 crate::frame::to_string(self.art, self.ranks, 1.0)
177 )?;
178 self.out.flush()
179 }
180
181 fn restore(&mut self) -> io::Result<()> {
182 if self.active {
183 self.active = false;
184 execute!(self.out, ResetColor, Show, LeaveAlternateScreen)?;
185 }
186 Ok(())
187 }
188}
189
190impl Drop for Reveal<'_> {
191 fn drop(&mut self) {
192 let _ = self.restore();
193 }
194}
195
196pub fn animate(
201 art: &Art,
202 ranks: &RankMap,
203 style: Style,
204 duration: Duration,
205 easing: Easing,
206) -> io::Result<()> {
207 if !io::stdout().is_terminal() {
208 print!("{}", crate::frame::to_string(art, ranks, 1.0));
209 return Ok(());
210 }
211
212 let mut reveal = Reveal::new(art, ranks, style)?;
213 let total = duration.as_secs_f32().max(0.001);
214 let frame = Duration::from_millis(16); let start = Instant::now();
216
217 for tick in 1.. {
218 let t = (start.elapsed().as_secs_f32() / total).min(1.0);
219 reveal.render(easing.apply(t))?;
220 if t >= 1.0 {
221 break;
222 }
223 if let Some(remaining) = (start + frame * tick).checked_duration_since(Instant::now()) {
226 std::thread::sleep(remaining);
227 }
228 }
229 reveal.finish()
230}
231
232fn paint(
234 out: &mut io::Stdout,
235 art: &Art,
236 ranks: &RankMap,
237 style: &Style,
238 state: &mut [CellState],
239 progress: f32,
240 (ox, oy): (u16, u16),
241) -> io::Result<()> {
242 let mut dirty = false;
243 for y in 0..art.height() {
244 let mut col = 0u16; for x in 0..art.width() {
246 let glyph = art.glyph(x, y);
247 let cw = glyph_cols(glyph);
248 let idx = art.index(x, y);
249 let target = match ranks.rank_at(x, y) {
250 Some(r) if r <= progress => {
251 let level = match style.palette {
252 Palette::Rainbow => GLOW_LEVELS,
255 Palette::Glow if style.feather <= 0.0 => GLOW_LEVELS,
256 Palette::Glow => {
257 let a = ((progress - r) / style.feather).clamp(0.0, 1.0);
258 (a * GLOW_LEVELS as f32).round() as u8
259 }
260 };
261 CellState::Lit(level)
262 }
263 _ => CellState::Hidden,
264 };
265
266 if state[idx] != target {
267 if !dirty {
268 queue!(out, Print(SYNC_BEGIN))?;
269 }
270 queue!(out, MoveTo(ox + col, oy + y))?;
271 match target {
272 CellState::Hidden => {
275 for _ in 0..cw {
276 queue!(out, Print(' '))?;
277 }
278 }
279 CellState::Lit(level) => {
280 if style.color {
281 let (r, g, b) = match style.palette {
282 Palette::Rainbow => rainbow_rgb(x, y, 0.0),
283 Palette::Glow => {
284 blend(style.head, style.body, level as f32 / GLOW_LEVELS as f32)
285 }
286 };
287 queue!(out, SetForegroundColor(Color::Rgb { r, g, b }))?;
288 }
289 queue!(out, Print(glyph))?;
290 }
291 }
292 state[idx] = target;
293 dirty = true;
294 }
295 col += cw;
296 }
297 }
298
299 if dirty {
300 queue!(out, ResetColor, Print(SYNC_END))?;
301 out.flush()?;
302 }
303 Ok(())
304}
305
306fn blend(a: (u8, u8, u8), b: (u8, u8, u8), s: f32) -> (u8, u8, u8) {
308 let lerp = |x: u8, y: u8| {
309 (x as f32 + (y as f32 - x as f32) * s)
310 .round()
311 .clamp(0.0, 255.0) as u8
312 };
313 (lerp(a.0, b.0), lerp(a.1, b.1), lerp(a.2, b.2))
314}
315
316pub(crate) fn frontier_rgb(style: &Style, progress: f32, rank: f32) -> (u8, u8, u8) {
319 if style.feather <= 0.0 {
320 return style.body;
321 }
322 let a = ((progress - rank) / style.feather).clamp(0.0, 1.0);
323 blend(style.head, style.body, a)
324}
325
326pub(crate) fn cell_rgb(
329 style: &Style,
330 progress: f32,
331 rank: f32,
332 x: u16,
333 y: u16,
334 t: f32,
335) -> (u8, u8, u8) {
336 match style.palette {
337 Palette::Glow => frontier_rgb(style, progress, rank),
338 Palette::Rainbow => rainbow_rgb(x, y, t),
339 }
340}
341
342fn rainbow_rgb(x: u16, y: u16, t: f32) -> (u8, u8, u8) {
344 let hue = (x as f32 * 0.05 + y as f32 * 0.12 + t * 0.4).rem_euclid(1.0);
345 hsl_to_rgb(hue, 0.95, 0.62)
346}
347
348fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
350 let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
351 let hp = h * 6.0;
352 let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
353 let (r, g, b) = match hp as u32 {
354 0 => (c, x, 0.0),
355 1 => (x, c, 0.0),
356 2 => (0.0, c, x),
357 3 => (0.0, x, c),
358 4 => (x, 0.0, c),
359 _ => (c, 0.0, x),
360 };
361 let m = l - c / 2.0;
362 let to = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
363 (to(r), to(g), to(b))
364}
365
366pub(crate) fn glyph_cols(c: char) -> u16 {
370 unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16
371}
372
373pub(crate) fn art_cols(art: &Art) -> u16 {
375 (0..art.height())
376 .map(|y| {
377 (0..art.width())
378 .map(|x| glyph_cols(art.glyph(x, y)))
379 .sum::<u16>()
380 })
381 .max()
382 .unwrap_or(0)
383}
384
385pub(crate) fn truncate_to_cols(s: &str, max: u16) -> String {
388 let mut out = String::new();
389 let mut used = 0u16;
390 for c in s.chars() {
391 let w = glyph_cols(c);
392 if used + w > max {
393 break;
394 }
395 out.push(c);
396 used += w;
397 }
398 out
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn display_width_counts_wide_glyphs() {
407 assert_eq!(glyph_cols('a'), 1);
408 assert_eq!(glyph_cols('世'), 2);
409 let art = Art::parse("a世\nbb"); assert_eq!(art_cols(&art), 3);
411 }
412
413 #[test]
414 fn truncate_respects_display_width() {
415 assert_eq!(truncate_to_cols("abc", 2), "ab");
416 assert_eq!(truncate_to_cols("a世", 3), "a世"); assert_eq!(truncate_to_cols("世界", 3), "世"); }
419}