Skip to main content

showcase/
showcase.rs

1use std::env;
2use std::fs;
3use std::io::{self, Write};
4use std::process::Command;
5use std::thread;
6use std::time::{Duration, Instant};
7
8use aisling::{Cell, Color, Effect, EffectConfig, EffectKind, Frame, Gradient, GradientDirection};
9
10const TEXT_MARKER: &str = "__KNOTT_TEXT__";
11
12fn main() {
13    let args = Args::parse();
14    let (terminal_width, terminal_height) = terminal_size();
15    let width = terminal_width.max(48);
16    let height = terminal_height.max(18);
17    let layout = Layout::new(width, height);
18    let text = args.text.unwrap_or_else(load_text_from_test_sh);
19    let theme = args.theme;
20    let panes = build_panes(&text, &layout, args.duration_frames, theme);
21    let pages = build_pages(&panes, &layout);
22    let mut stdout = io::stdout();
23    let _guard = TerminalGuard::enter(&mut stdout);
24
25    let frame_delay = Duration::from_millis((1000 / args.fps.max(1)) as u64);
26    let page_ticks = (args.fps.max(1) * args.page_seconds.max(1)) as usize;
27    let max_ticks = args
28        .seconds
29        .map(|seconds| (seconds * args.fps.max(1)) as usize);
30    let start = Instant::now();
31    let mut tick = 0usize;
32
33    loop {
34        let page = (tick / page_ticks) % pages.len().max(1);
35        let screen = compose_screen(
36            &panes,
37            &pages,
38            &layout,
39            page,
40            tick,
41            &text,
42            start.elapsed(),
43            theme,
44        );
45        write!(stdout, "\x1b[H").unwrap();
46        write_screen(&mut stdout, &screen, &layout).unwrap();
47        stdout.flush().unwrap();
48
49        tick += 1;
50        if args.once || max_ticks.is_some_and(|limit| tick >= limit) {
51            break;
52        }
53        thread::sleep(frame_delay);
54    }
55}
56
57#[derive(Clone, Debug)]
58struct Args {
59    text: Option<String>,
60    seconds: Option<u64>,
61    fps: u64,
62    page_seconds: u64,
63    duration_frames: usize,
64    once: bool,
65    theme: Theme,
66}
67
68impl Args {
69    fn parse() -> Self {
70        let mut args = env::args().skip(1);
71        let mut parsed = Self {
72            text: None,
73            seconds: Some(45),
74            fps: 24,
75            page_seconds: 7,
76            duration_frames: 96,
77            once: false,
78            theme: Theme::Curated,
79        };
80        while let Some(arg) = args.next() {
81            match arg.as_str() {
82                "--text" => parsed.text = args.next(),
83                "--theme" => {
84                    parsed.theme = args
85                        .next()
86                        .as_deref()
87                        .and_then(Theme::parse)
88                        .unwrap_or(parsed.theme);
89                }
90                "--industrial" | "--manly" => parsed.theme = Theme::Industrial,
91                "--rainbow" | "--neon" => parsed.theme = Theme::Rainbow,
92                "--curated" | "--bold" | "--default" => parsed.theme = Theme::Curated,
93                "--seconds" => parsed.seconds = args.next().and_then(|value| value.parse().ok()),
94                "--fps" => {
95                    parsed.fps = args
96                        .next()
97                        .and_then(|value| value.parse().ok())
98                        .unwrap_or(parsed.fps)
99                }
100                "--page-seconds" => {
101                    parsed.page_seconds = args
102                        .next()
103                        .and_then(|value| value.parse().ok())
104                        .unwrap_or(parsed.page_seconds);
105                }
106                "--duration-frames" => {
107                    parsed.duration_frames = args
108                        .next()
109                        .and_then(|value| value.parse().ok())
110                        .unwrap_or(parsed.duration_frames);
111                }
112                "--forever" => parsed.seconds = None,
113                "--once" => parsed.once = true,
114                value => {
115                    if parsed.text.is_none() {
116                        parsed.text = Some(value.to_string());
117                    }
118                }
119            }
120        }
121        parsed.duration_frames = parsed.duration_frames.max(16);
122        parsed
123    }
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127enum Theme {
128    Curated,
129    Rainbow,
130    Industrial,
131}
132
133impl Theme {
134    fn parse(value: &str) -> Option<Self> {
135        match value.to_ascii_lowercase().as_str() {
136            "curated" | "bold" | "default" => Some(Self::Curated),
137            "rainbow" | "neon" => Some(Self::Rainbow),
138            "industrial" | "steel" | "dark" | "manly" => Some(Self::Industrial),
139            _ => None,
140        }
141    }
142
143    fn name(self) -> &'static str {
144        match self {
145            Self::Curated => "curated",
146            Self::Rainbow => "rainbow",
147            Self::Industrial => "industrial",
148        }
149    }
150
151    fn screen_bg(self) -> Color {
152        match self {
153            Self::Curated => Color::rgb(5, 8, 12),
154            Self::Rainbow => Color::rgb(4, 6, 13),
155            Self::Industrial => Color::rgb(10, 11, 10),
156        }
157    }
158
159    fn header_bg(self) -> Color {
160        match self {
161            Self::Curated => Color::rgb(9, 14, 20),
162            Self::Rainbow => Color::rgb(8, 10, 24),
163            Self::Industrial => Color::rgb(18, 17, 14),
164        }
165    }
166
167    fn pane_bg(self) -> Color {
168        match self {
169            Self::Curated => Color::rgb(11, 16, 23),
170            Self::Rainbow => Color::rgb(8, 11, 22),
171            Self::Industrial => Color::rgb(16, 17, 16),
172        }
173    }
174
175    fn title_color(self) -> Color {
176        match self {
177            Self::Curated => Color::rgb(255, 176, 74),
178            Self::Rainbow => Color::rgb(0, 255, 210),
179            Self::Industrial => Color::rgb(216, 166, 72),
180        }
181    }
182
183    fn subtitle_color(self) -> Color {
184        match self {
185            Self::Curated => Color::rgb(112, 176, 255),
186            Self::Rainbow => Color::rgb(255, 120, 220),
187            Self::Industrial => Color::rgb(150, 154, 145),
188        }
189    }
190
191    fn source_color(self) -> Color {
192        match self {
193            Self::Curated => Color::rgb(188, 207, 224),
194            Self::Rainbow => Color::rgb(148, 180, 255),
195            Self::Industrial => Color::rgb(124, 134, 116),
196        }
197    }
198
199    fn footer_line(self) -> Color {
200        match self {
201            Self::Curated => Color::rgb(62, 85, 105),
202            Self::Rainbow => Color::rgb(70, 80, 130),
203            Self::Industrial => Color::rgb(74, 69, 58),
204        }
205    }
206
207    fn footer_text(self) -> Color {
208        match self {
209            Self::Curated => Color::rgb(176, 187, 196),
210            Self::Rainbow => Color::rgb(160, 170, 190),
211            Self::Industrial => Color::rgb(158, 146, 122),
212        }
213    }
214
215    fn base_cell(self) -> ScreenCell {
216        ScreenCell {
217            ch: ' ',
218            fg: Some(match self {
219                Self::Curated => Color::rgb(40, 52, 62),
220                Self::Rainbow => Color::rgb(32, 36, 48),
221                Self::Industrial => Color::rgb(42, 42, 38),
222            }),
223            bg: Some(self.screen_bg()),
224            bold: false,
225        }
226    }
227
228    fn accent(self, kind: EffectKind, index: usize) -> Color {
229        match self {
230            Self::Curated => curated_color(kind, index),
231            Self::Rainbow => rainbow_color(index),
232            Self::Industrial => industrial_color(index),
233        }
234    }
235
236    fn header_accent(self, index: usize) -> Color {
237        match self {
238            Self::Curated => curated_header_color(index),
239            Self::Rainbow => rainbow_color(index),
240            Self::Industrial => industrial_color(index),
241        }
242    }
243
244    fn tune_cell_color(self, color: Color, accent: Color) -> Color {
245        match self {
246            Self::Curated => color.dim(0.72).blend(accent, 0.38).brighten(0.12),
247            Self::Rainbow => color,
248            Self::Industrial => color.dim(0.56).blend(accent, 0.42).brighten(0.04),
249        }
250    }
251}
252
253struct TerminalGuard;
254
255impl TerminalGuard {
256    fn enter(stdout: &mut io::Stdout) -> Self {
257        write!(stdout, "\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H").unwrap();
258        stdout.flush().unwrap();
259        Self
260    }
261}
262
263impl Drop for TerminalGuard {
264    fn drop(&mut self) {
265        let mut stdout = io::stdout();
266        let _ = write!(stdout, "\x1b[0m\x1b[?25h\x1b[?1049l");
267        let _ = stdout.flush();
268    }
269}
270
271#[derive(Clone, Copy, Debug)]
272struct Layout {
273    width: usize,
274    height: usize,
275    columns: usize,
276    rows: usize,
277    pane_width: usize,
278    pane_height: usize,
279    header_height: usize,
280    footer_height: usize,
281}
282
283impl Layout {
284    fn new(width: usize, height: usize) -> Self {
285        let header_height = 4;
286        let footer_height = 2;
287        let usable_height = height.saturating_sub(header_height + footer_height).max(10);
288        let columns = match width {
289            0..=63 => 1,
290            64..=95 => 2,
291            96..=131 => 3,
292            132..=171 => 4,
293            _ => 5,
294        };
295        let rows = (usable_height / 8).clamp(2, 6);
296        let pane_width = (width / columns).max(14);
297        let pane_height = (usable_height / rows).max(7);
298        Self {
299            width,
300            height,
301            columns,
302            rows,
303            pane_width,
304            pane_height,
305            header_height,
306            footer_height,
307        }
308    }
309}
310
311#[derive(Clone, Copy, Debug)]
312struct PaneShape {
313    columns: usize,
314    rows: usize,
315    stagger: bool,
316}
317
318#[derive(Clone, Copy, Debug)]
319struct PanePlacement {
320    pane_index: usize,
321    column: usize,
322    row: usize,
323    x: usize,
324    y: usize,
325    width: usize,
326    height: usize,
327}
328
329#[derive(Clone)]
330struct PaneRuntime {
331    index: usize,
332    kind: EffectKind,
333    frames: Vec<Frame>,
334    offset: usize,
335    speed_numerator: usize,
336    accent: Color,
337    shape: PaneShape,
338}
339
340#[derive(Clone, Copy, PartialEq, Eq)]
341struct ScreenCell {
342    ch: char,
343    fg: Option<Color>,
344    bg: Option<Color>,
345    bold: bool,
346}
347
348impl Default for ScreenCell {
349    fn default() -> Self {
350        Self {
351            ch: ' ',
352            fg: Some(Color::rgb(32, 36, 48)),
353            bg: Some(Color::rgb(4, 6, 13)),
354            bold: false,
355        }
356    }
357}
358
359fn build_panes(
360    text: &str,
361    layout: &Layout,
362    base_duration: usize,
363    theme: Theme,
364) -> Vec<PaneRuntime> {
365    EffectKind::all()
366        .iter()
367        .enumerate()
368        .map(|(index, kind)| {
369            let shape = pane_shape(*kind, index, layout);
370            let (pane_width, pane_height) = pane_size(layout, shape);
371            let inner_width = pane_width.saturating_sub(2).max(8);
372            let inner_height = pane_height.saturating_sub(2).max(3);
373            let snippet = snippet(text, index, inner_width, inner_height);
374            let accent = theme.accent(*kind, index);
375            let duration = base_duration + (index % 9) * 5;
376            let config = EffectConfig::default()
377                .with_duration(duration)
378                .with_seed(1009 + index as u64 * 97)
379                .with_canvas_size(inner_width, inner_height)
380                .with_gradient(
381                    effect_gradient(*kind, index, theme),
382                    gradient_direction(index),
383                );
384            let frames = Effect::with_config(*kind, snippet, config).frames();
385            PaneRuntime {
386                index,
387                kind: *kind,
388                frames,
389                offset: index * 13,
390                speed_numerator: 1 + (index % 4),
391                accent,
392                shape,
393            }
394        })
395        .collect()
396}
397
398fn build_pages(panes: &[PaneRuntime], layout: &Layout) -> Vec<Vec<PanePlacement>> {
399    let mut pages = Vec::new();
400    let mut occupied = vec![false; layout.columns * layout.rows];
401    let mut current = Vec::new();
402
403    for pane in panes {
404        if let Some(placement) = place_pane(&occupied, layout, pane) {
405            mark_occupied(&mut occupied, layout, pane.shape, placement);
406            current.push(placement);
407            continue;
408        }
409
410        if !current.is_empty() {
411            pages.push(current);
412        }
413        occupied = vec![false; layout.columns * layout.rows];
414        current = Vec::new();
415
416        let placement = place_pane(&occupied, layout, pane)
417            .unwrap_or_else(|| fallback_placement(layout, pane.index));
418        mark_occupied(&mut occupied, layout, pane.shape, placement);
419        current.push(placement);
420    }
421
422    if !current.is_empty() {
423        pages.push(current);
424    }
425
426    if pages.is_empty() {
427        pages.push(Vec::new());
428    }
429    pages
430}
431
432fn pane_shape(kind: EffectKind, index: usize, layout: &Layout) -> PaneShape {
433    let (columns, rows) = match kind {
434        EffectKind::Matrix => (3, 2),
435        EffectKind::Rain | EffectKind::Thunderstorm | EffectKind::Pour => (1, 2),
436        EffectKind::VhsTape
437        | EffectKind::SynthGrid
438        | EffectKind::LaserEtch
439        | EffectKind::Beams
440        | EffectKind::ColorShift
441        | EffectKind::Overflow
442        | EffectKind::Print
443        | EffectKind::Spotlights
444        | EffectKind::Waves => (2, 1),
445        EffectKind::Blackhole | EffectKind::Rings | EffectKind::Fireworks => (2, 2),
446        _ => (1, 1),
447    };
448    PaneShape {
449        columns: columns.min(layout.columns).max(1),
450        rows: rows.min(layout.rows).max(1),
451        stagger: index % 2 == 1,
452    }
453}
454
455fn pane_size(layout: &Layout, shape: PaneShape) -> (usize, usize) {
456    let width = (layout.pane_width * shape.columns)
457        .min(layout.width)
458        .max(12);
459    let height = (layout.pane_height * shape.rows)
460        .min(
461            layout
462                .height
463                .saturating_sub(layout.header_height + layout.footer_height),
464        )
465        .max(6);
466    (width, height)
467}
468
469fn place_pane(occupied: &[bool], layout: &Layout, pane: &PaneRuntime) -> Option<PanePlacement> {
470    let shape = pane.shape;
471    if shape.columns > layout.columns || shape.rows > layout.rows {
472        return None;
473    }
474    for row in 0..=layout.rows.saturating_sub(shape.rows) {
475        for column in 0..=layout.columns.saturating_sub(shape.columns) {
476            if is_free(occupied, layout, column, row, shape) {
477                return Some(pixel_placement(layout, pane.index, shape, column, row));
478            }
479        }
480    }
481    None
482}
483
484fn is_free(
485    occupied: &[bool],
486    layout: &Layout,
487    column: usize,
488    row: usize,
489    shape: PaneShape,
490) -> bool {
491    for dy in 0..shape.rows {
492        for dx in 0..shape.columns {
493            let index = (row + dy) * layout.columns + column + dx;
494            if occupied.get(index).copied().unwrap_or(true) {
495                return false;
496            }
497        }
498    }
499    true
500}
501
502fn mark_occupied(
503    occupied: &mut [bool],
504    layout: &Layout,
505    shape: PaneShape,
506    placement: PanePlacement,
507) {
508    for dy in 0..shape.rows {
509        for dx in 0..shape.columns {
510            let index = (placement.row + dy) * layout.columns + placement.column + dx;
511            if let Some(cell) = occupied.get_mut(index) {
512                *cell = true;
513            }
514        }
515    }
516}
517
518fn pixel_placement(
519    layout: &Layout,
520    pane_index: usize,
521    shape: PaneShape,
522    column: usize,
523    row: usize,
524) -> PanePlacement {
525    let (base_width, base_height) = pane_size(layout, shape);
526    let offset_x = if shape.stagger && column + shape.columns < layout.columns {
527        (layout.pane_width / 5).clamp(1, 5)
528    } else {
529        0
530    };
531    let offset_y = if shape.stagger && row + shape.rows < layout.rows {
532        1
533    } else {
534        0
535    };
536    let x = column * layout.pane_width + offset_x;
537    let y = layout.header_height + row * layout.pane_height + offset_y;
538    let max_height = layout.height.saturating_sub(layout.footer_height + y);
539    let adjusted_width = base_width.saturating_sub(offset_x).max(4);
540    let adjusted_height = base_height.saturating_sub(offset_y).max(4);
541    PanePlacement {
542        pane_index,
543        column,
544        row,
545        x,
546        y,
547        width: adjusted_width.min(layout.width.saturating_sub(x)).max(4),
548        height: adjusted_height.min(max_height).max(4),
549    }
550}
551
552fn fallback_placement(layout: &Layout, pane_index: usize) -> PanePlacement {
553    let shape = PaneShape {
554        columns: 1,
555        rows: 1,
556        stagger: false,
557    };
558    pixel_placement(layout, pane_index, shape, 0, 0)
559}
560
561fn compose_screen(
562    panes: &[PaneRuntime],
563    pages: &[Vec<PanePlacement>],
564    layout: &Layout,
565    page: usize,
566    tick: usize,
567    text: &str,
568    elapsed: Duration,
569    theme: Theme,
570) -> Vec<ScreenCell> {
571    let mut screen = vec![theme.base_cell(); layout.width * layout.height];
572    paint_header(
573        &mut screen,
574        layout,
575        panes.len(),
576        page,
577        pages.len(),
578        pages.get(page).map_or(0, Vec::len),
579        text,
580        elapsed,
581        theme,
582    );
583
584    if let Some(placements) = pages.get(page) {
585        for placement in placements {
586            if let Some(pane) = panes.get(placement.pane_index) {
587                paint_pane(&mut screen, layout, *placement, pane, tick, theme);
588            }
589        }
590    }
591
592    paint_footer(&mut screen, layout, theme);
593    screen
594}
595
596fn paint_header(
597    screen: &mut [ScreenCell],
598    layout: &Layout,
599    effect_count: usize,
600    page: usize,
601    page_count: usize,
602    pane_count: usize,
603    text: &str,
604    elapsed: Duration,
605    theme: Theme,
606) {
607    let title = " AISLING // ALL EFFECTS MULTI-PANE SHOWCASE ";
608    let subtitle = format!(
609        " {} effects | {} theme | masonry {}x{} | {} panes here | page {}/{} | {:.1}s ",
610        effect_count,
611        theme.name(),
612        layout.columns,
613        layout.rows,
614        pane_count,
615        page + 1,
616        page_count,
617        elapsed.as_secs_f32()
618    );
619    let source = first_words(text, layout.width.saturating_sub(4));
620    for x in 0..layout.width {
621        let color = theme.header_accent(x / 10);
622        put(
623            screen,
624            layout,
625            x,
626            0,
627            '═',
628            Some(color),
629            Some(theme.header_bg()),
630            true,
631        );
632        put(
633            screen,
634            layout,
635            x,
636            3,
637            '─',
638            Some(color.dim(0.55)),
639            Some(theme.header_bg()),
640            false,
641        );
642    }
643    put_text_center(
644        screen,
645        layout,
646        1,
647        title,
648        Some(theme.title_color()),
649        Some(theme.header_bg()),
650        true,
651    );
652    put_text_center(
653        screen,
654        layout,
655        2,
656        &subtitle,
657        Some(theme.subtitle_color()),
658        Some(theme.header_bg()),
659        false,
660    );
661    put_text(
662        screen,
663        layout,
664        2,
665        3,
666        &source,
667        Some(theme.source_color()),
668        Some(theme.header_bg()),
669        false,
670    );
671}
672
673fn paint_footer(screen: &mut [ScreenCell], layout: &Layout, theme: Theme) {
674    let y = layout.height.saturating_sub(layout.footer_height);
675    for x in 0..layout.width {
676        put(
677            screen,
678            layout,
679            x,
680            y,
681            '─',
682            Some(theme.footer_line()),
683            Some(theme.screen_bg()),
684            false,
685        );
686    }
687    put_text_center(
688        screen,
689        layout,
690        y + 1,
691        " Ctrl-C exits | default is curated | --rainbow for full color | --industrial for steel/amber | matrix gets a long pane ",
692        Some(theme.footer_text()),
693        Some(theme.screen_bg()),
694        false,
695    );
696}
697
698fn paint_pane(
699    screen: &mut [ScreenCell],
700    layout: &Layout,
701    placement: PanePlacement,
702    pane: &PaneRuntime,
703    tick: usize,
704    theme: Theme,
705) {
706    let x = placement.x;
707    let y = placement.y;
708    let w = placement.width.min(layout.width.saturating_sub(x));
709    let h = placement
710        .height
711        .min(layout.height.saturating_sub(y + layout.footer_height));
712    if w < 4 || h < 4 || pane.frames.is_empty() {
713        return;
714    }
715    let accent = pane.accent;
716    let bg = Some(theme.pane_bg());
717    let dim = Some(accent.dim(0.48));
718
719    for dx in 0..w {
720        put(screen, layout, x + dx, y, '─', dim, bg, false);
721        put(screen, layout, x + dx, y + h - 1, '─', dim, bg, false);
722    }
723    for dy in 0..h {
724        put(screen, layout, x, y + dy, '│', dim, bg, false);
725        put(screen, layout, x + w - 1, y + dy, '│', dim, bg, false);
726    }
727    put(screen, layout, x, y, '╭', Some(accent), bg, true);
728    put(screen, layout, x + w - 1, y, '╮', Some(accent), bg, true);
729    put(screen, layout, x, y + h - 1, '╰', Some(accent), bg, true);
730    put(
731        screen,
732        layout,
733        x + w - 1,
734        y + h - 1,
735        '╯',
736        Some(accent),
737        bg,
738        true,
739    );
740
741    let title = format!(" {:02} {} ", pane.index + 1, pane.kind.name());
742    put_text(
743        screen,
744        layout,
745        x + 2,
746        y,
747        &truncate(&title, w.saturating_sub(4)),
748        Some(accent.brighten(0.25)),
749        bg,
750        true,
751    );
752
753    let frame_index = ((tick * pane.speed_numerator / 2) + pane.offset) % pane.frames.len();
754    let frame = &pane.frames[frame_index];
755    let pulse = (frame_index as f32 / pane.frames.len() as f32 * std::f32::consts::TAU)
756        .sin()
757        .abs();
758    let meter_width = w.saturating_sub(4);
759    let meter_fill = (meter_width as f32 * frame_index as f32 / pane.frames.len() as f32) as usize;
760    for i in 0..meter_width {
761        let ch = if i <= meter_fill { '━' } else { '·' };
762        let color = if i <= meter_fill {
763            accent.brighten(0.15 + pulse * 0.25)
764        } else {
765            accent.dim(0.25)
766        };
767        put(
768            screen,
769            layout,
770            x + 2 + i,
771            y + h - 1,
772            ch,
773            Some(color),
774            bg,
775            false,
776        );
777    }
778
779    for fy in 0..pane.frames[frame_index].height().min(h.saturating_sub(2)) {
780        for fx in 0..pane.frames[frame_index].width().min(w.saturating_sub(2)) {
781            if let Some(cell) = frame.cell(fx, fy) {
782                let target_x = x + 1 + fx;
783                let target_y = y + 1 + fy;
784                let mapped = map_cell(cell, pane.accent, theme);
785                put(
786                    screen,
787                    layout,
788                    target_x,
789                    target_y,
790                    mapped.ch,
791                    mapped.fg,
792                    mapped.bg.or(bg),
793                    mapped.bold,
794                );
795            }
796        }
797    }
798}
799
800fn map_cell(cell: &Cell, accent: Color, theme: Theme) -> ScreenCell {
801    let fg = cell
802        .colors
803        .fg
804        .map(|color| theme.tune_cell_color(color, accent))
805        .or(Some(accent));
806    ScreenCell {
807        ch: cell.ch,
808        fg,
809        bg: cell.colors.bg,
810        bold: cell.bold,
811    }
812}
813
814fn write_screen(stdout: &mut io::Stdout, screen: &[ScreenCell], layout: &Layout) -> io::Result<()> {
815    let width = layout.width;
816    let height = layout.height;
817    let mut active = ScreenCell {
818        ch: '\0',
819        fg: None,
820        bg: None,
821        bold: false,
822    };
823    for y in 0..height {
824        if y > 0 {
825            write!(stdout, "\x1b[0m\r\n")?;
826            active = ScreenCell {
827                ch: '\0',
828                fg: None,
829                bg: None,
830                bold: false,
831            };
832        }
833        for x in 0..width {
834            let cell = screen.get(y * width + x).copied().unwrap_or_default();
835            if same_style(active, cell) {
836                write!(stdout, "{}", cell.ch)?;
837            } else {
838                write!(stdout, "\x1b[0m{}{}", style_prefix(cell), cell.ch)?;
839                active = cell;
840            }
841        }
842    }
843    write!(stdout, "\x1b[0m")?;
844    Ok(())
845}
846
847fn same_style(a: ScreenCell, b: ScreenCell) -> bool {
848    a.fg == b.fg && a.bg == b.bg && a.bold == b.bold
849}
850
851fn style_prefix(cell: ScreenCell) -> String {
852    let mut parts = Vec::new();
853    if cell.bold {
854        parts.push("1".to_string());
855    }
856    if let Some(fg) = cell.fg {
857        parts.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
858    }
859    if let Some(bg) = cell.bg {
860        parts.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
861    }
862    if parts.is_empty() {
863        String::new()
864    } else {
865        format!("\x1b[{}m", parts.join(";"))
866    }
867}
868
869fn put(
870    screen: &mut [ScreenCell],
871    layout: &Layout,
872    x: usize,
873    y: usize,
874    ch: char,
875    fg: Option<Color>,
876    bg: Option<Color>,
877    bold: bool,
878) {
879    if x < layout.width && y < layout.height {
880        screen[y * layout.width + x] = ScreenCell { ch, fg, bg, bold };
881    }
882}
883
884fn put_text(
885    screen: &mut [ScreenCell],
886    layout: &Layout,
887    x: usize,
888    y: usize,
889    text: &str,
890    fg: Option<Color>,
891    bg: Option<Color>,
892    bold: bool,
893) {
894    for (offset, ch) in text.chars().enumerate() {
895        put(screen, layout, x + offset, y, ch, fg, bg, bold);
896    }
897}
898
899fn put_text_center(
900    screen: &mut [ScreenCell],
901    layout: &Layout,
902    y: usize,
903    text: &str,
904    fg: Option<Color>,
905    bg: Option<Color>,
906    bold: bool,
907) {
908    let len = text.chars().count();
909    let x = layout.width.saturating_sub(len) / 2;
910    put_text(screen, layout, x, y, text, fg, bg, bold);
911}
912
913fn terminal_size() -> (usize, usize) {
914    if let Ok(output) = Command::new("stty").arg("size").output() {
915        if output.status.success() {
916            if let Ok(text) = String::from_utf8(output.stdout) {
917                let mut parts = text.split_whitespace();
918                if let (Some(rows), Some(columns)) = (parts.next(), parts.next()) {
919                    if let (Ok(rows), Ok(columns)) =
920                        (rows.parse::<usize>(), columns.parse::<usize>())
921                    {
922                        return (columns, rows);
923                    }
924                }
925            }
926        }
927    }
928    let width = env::var("COLUMNS")
929        .ok()
930        .and_then(|value| value.parse().ok())
931        .unwrap_or(132);
932    let height = env::var("LINES")
933        .ok()
934        .and_then(|value| value.parse().ok())
935        .unwrap_or(42);
936    (width, height)
937}
938
939fn load_text_from_test_sh() -> String {
940    let fallback =
941        "Knott Dynamics\nHumanoid robotics through tendons, leverage, and motion.".to_string();
942    let Ok(script) = fs::read_to_string("test.sh") else {
943        return fallback;
944    };
945    let mut sections = script.split(TEXT_MARKER);
946    sections.next();
947    sections
948        .next()
949        .map(|text| text.trim_matches(['\n', '\r']).to_string())
950        .filter(|text| !text.is_empty())
951        .unwrap_or(fallback)
952}
953
954fn snippet(text: &str, index: usize, width: usize, height: usize) -> String {
955    let words: Vec<&str> = text.split_whitespace().collect();
956    if words.is_empty() {
957        return "Aisling".to_string();
958    }
959    let start = (index * 9) % words.len();
960    let mut lines = Vec::new();
961    let mut line = String::new();
962    let mut cursor = start;
963    while lines.len() < height.max(1) {
964        let word = words[cursor % words.len()];
965        let next_len = if line.is_empty() {
966            word.len()
967        } else {
968            line.len() + 1 + word.len()
969        };
970        if next_len > width.max(8) {
971            lines.push(line);
972            line = String::new();
973            if lines.len() >= height.max(1) {
974                break;
975            }
976        }
977        if !line.is_empty() {
978            line.push(' ');
979        }
980        line.push_str(word);
981        cursor += 1;
982        if cursor - start > width.max(8) * height.max(1) {
983            break;
984        }
985    }
986    if !line.is_empty() && lines.len() < height.max(1) {
987        lines.push(line);
988    }
989    lines.join("\n")
990}
991
992fn first_words(text: &str, width: usize) -> String {
993    let mut out = String::new();
994    for word in text.split_whitespace() {
995        if out.len() + word.len() + 1 > width {
996            break;
997        }
998        if !out.is_empty() {
999            out.push(' ');
1000        }
1001        out.push_str(word);
1002    }
1003    out
1004}
1005
1006fn truncate(text: &str, max_width: usize) -> String {
1007    if text.chars().count() <= max_width {
1008        return text.to_string();
1009    }
1010    text.chars()
1011        .take(max_width.saturating_sub(1))
1012        .chain(std::iter::once('…'))
1013        .collect()
1014}
1015
1016fn effect_gradient(kind: EffectKind, index: usize, theme: Theme) -> Gradient {
1017    let a = theme.accent(kind, index);
1018    let b = theme.accent(kind, index + 5).brighten(match theme {
1019        Theme::Curated => 0.18,
1020        Theme::Rainbow => 0.25,
1021        Theme::Industrial => 0.10,
1022    });
1023    let c = theme.accent(kind, index + 11);
1024    Gradient::new(vec![a, b, c], 18)
1025}
1026
1027fn gradient_direction(index: usize) -> GradientDirection {
1028    match index % 4 {
1029        0 => GradientDirection::Diagonal,
1030        1 => GradientDirection::Horizontal,
1031        2 => GradientDirection::Vertical,
1032        _ => GradientDirection::Radial,
1033    }
1034}
1035
1036fn curated_color(kind: EffectKind, index: usize) -> Color {
1037    match kind {
1038        EffectKind::Matrix | EffectKind::BinaryPath | EffectKind::Decrypt => {
1039            Color::rgb(55, 220, 110)
1040        }
1041        EffectKind::Burn | EffectKind::Fireworks | EffectKind::LaserEtch => {
1042            Color::rgb(255, 126, 48)
1043        }
1044        EffectKind::Rain | EffectKind::Thunderstorm | EffectKind::Bubbles => {
1045            Color::rgb(70, 165, 255)
1046        }
1047        EffectKind::Blackhole | EffectKind::Rings | EffectKind::Spotlights => {
1048            Color::rgb(168, 118, 255)
1049        }
1050        EffectKind::Smoke | EffectKind::Crumble | EffectKind::VhsTape | EffectKind::Unstable => {
1051            Color::rgb(185, 196, 204)
1052        }
1053        EffectKind::SynthGrid | EffectKind::ColorShift | EffectKind::Waves => {
1054            Color::rgb(0, 212, 200)
1055        }
1056        EffectKind::Print | EffectKind::Overflow | EffectKind::Sweep | EffectKind::Wipe => {
1057            Color::rgb(255, 184, 82)
1058        }
1059        _ => curated_header_color(index),
1060    }
1061}
1062
1063fn curated_header_color(index: usize) -> Color {
1064    const COLORS: [Color; 8] = [
1065        Color::rgb(255, 170, 60),
1066        Color::rgb(74, 156, 255),
1067        Color::rgb(55, 210, 118),
1068        Color::rgb(214, 95, 66),
1069        Color::rgb(175, 120, 255),
1070        Color::rgb(0, 196, 190),
1071        Color::rgb(224, 230, 236),
1072        Color::rgb(235, 136, 65),
1073    ];
1074    COLORS[index % COLORS.len()]
1075}
1076
1077fn rainbow_color(index: usize) -> Color {
1078    const COLORS: [Color; 12] = [
1079        Color::rgb(0, 255, 210),
1080        Color::rgb(255, 76, 210),
1081        Color::rgb(130, 98, 255),
1082        Color::rgb(0, 210, 255),
1083        Color::rgb(255, 185, 60),
1084        Color::rgb(130, 255, 75),
1085        Color::rgb(255, 80, 88),
1086        Color::rgb(70, 140, 255),
1087        Color::rgb(220, 255, 90),
1088        Color::rgb(255, 120, 45),
1089        Color::rgb(190, 120, 255),
1090        Color::rgb(80, 255, 170),
1091    ];
1092    COLORS[index % COLORS.len()]
1093}
1094
1095fn industrial_color(index: usize) -> Color {
1096    const COLORS: [Color; 12] = [
1097        Color::rgb(178, 126, 45),
1098        Color::rgb(115, 126, 112),
1099        Color::rgb(83, 92, 100),
1100        Color::rgb(136, 72, 48),
1101        Color::rgb(148, 132, 92),
1102        Color::rgb(78, 98, 68),
1103        Color::rgb(156, 86, 42),
1104        Color::rgb(96, 111, 120),
1105        Color::rgb(119, 103, 78),
1106        Color::rgb(100, 78, 64),
1107        Color::rgb(130, 118, 98),
1108        Color::rgb(68, 84, 74),
1109    ];
1110    COLORS[index % COLORS.len()]
1111}