Skip to main content

story/
story.rs

1use std::env;
2use std::io::{self, Write};
3use std::process::Command;
4use std::thread;
5use std::time::Duration;
6
7use aisling::{
8    Cell, Color, Effect, EffectConfig, EffectKind, Frame, Gradient, GradientDirection, Loader,
9    LoaderConfig, LoaderKind, LoaderProgress,
10};
11
12const TEST_SH: &str = include_str!("../test.sh");
13const TEXT_MARKER: &str = "__KNOTT_TEXT__";
14
15fn main() {
16    let args = Args::parse();
17    let (terminal_width, terminal_height) = terminal_size();
18    let width = terminal_width.max(112);
19    let height = terminal_height.max(36);
20    let fps = args.fps.max(1);
21    let scene_ticks = (args.scene_seconds.max(1) * fps) as usize;
22    let layout = Layout::new(width, height);
23    let source = load_knott_text();
24    let scenes = build_scenes(&source, &layout, scene_ticks);
25    let total_ticks = args
26        .seconds
27        .map(|seconds| (seconds * fps) as usize)
28        .unwrap_or(scene_ticks * scenes.len());
29    let frame_delay = Duration::from_millis((1000 / fps) as u64);
30    let mut stdout = io::stdout();
31    let _guard = TerminalGuard::enter(&mut stdout, args.alt_screen);
32    let tasks = story_tasks();
33    let mut tick = 0usize;
34
35    loop {
36        let scene_index = (tick / scene_ticks) % scenes.len().max(1);
37        let scene_tick = tick % scene_ticks;
38        let frame = compose_story(
39            &layout,
40            &scenes,
41            scene_index,
42            scene_tick,
43            tick,
44            scene_ticks,
45            total_ticks.max(1),
46            &tasks,
47        );
48        write!(stdout, "\x1b[H{}\x1b[0m", frame.to_ansi_string()).unwrap();
49        stdout.flush().unwrap();
50
51        tick += 1;
52        if args.once || tick >= total_ticks {
53            break;
54        }
55        thread::sleep(frame_delay);
56    }
57}
58
59#[derive(Clone, Debug)]
60struct Args {
61    seconds: Option<u64>,
62    fps: u64,
63    scene_seconds: u64,
64    once: bool,
65    alt_screen: bool,
66}
67
68impl Args {
69    fn parse() -> Self {
70        let mut args = env::args().skip(1);
71        let mut parsed = Self {
72            seconds: None,
73            fps: 12,
74            scene_seconds: 7,
75            once: false,
76            alt_screen: true,
77        };
78        while let Some(arg) = args.next() {
79            match arg.as_str() {
80                "--seconds" => parsed.seconds = args.next().and_then(|value| value.parse().ok()),
81                "--fps" => {
82                    parsed.fps = args
83                        .next()
84                        .and_then(|value| value.parse().ok())
85                        .unwrap_or(parsed.fps)
86                }
87                "--scene-seconds" => {
88                    parsed.scene_seconds = args
89                        .next()
90                        .and_then(|value| value.parse().ok())
91                        .unwrap_or(parsed.scene_seconds)
92                }
93                "--once" => parsed.once = true,
94                "--no-alt" => parsed.alt_screen = false,
95                "-h" | "--help" | "help" => {
96                    usage();
97                    std::process::exit(0);
98                }
99                _ => {}
100            }
101        }
102        parsed
103    }
104}
105
106#[derive(Clone, Copy, Debug)]
107struct Rect {
108    x: usize,
109    y: usize,
110    w: usize,
111    h: usize,
112}
113
114#[derive(Clone, Debug)]
115struct Layout {
116    width: usize,
117    height: usize,
118    header: Rect,
119    stage: Rect,
120    systems: Rect,
121    bottom: Rect,
122}
123
124impl Layout {
125    fn new(width: usize, height: usize) -> Self {
126        let header_h = 4;
127        let bottom_h = 8.min(height.saturating_sub(header_h + 10).max(5));
128        let middle_h = height.saturating_sub(header_h + bottom_h).max(12);
129        let systems_w = (width / 3).clamp(34, 56).min(width.saturating_sub(46));
130        let stage_w = width.saturating_sub(systems_w + 3).max(40);
131        Self {
132            width,
133            height,
134            header: Rect {
135                x: 0,
136                y: 0,
137                w: width,
138                h: header_h,
139            },
140            stage: Rect {
141                x: 1,
142                y: header_h,
143                w: stage_w,
144                h: middle_h,
145            },
146            systems: Rect {
147                x: stage_w + 2,
148                y: header_h,
149                w: systems_w,
150                h: middle_h,
151            },
152            bottom: Rect {
153                x: 1,
154                y: header_h + middle_h,
155                w: width.saturating_sub(2).max(1),
156                h: bottom_h,
157            },
158        }
159    }
160}
161
162#[derive(Clone, Debug)]
163struct Scene {
164    title: &'static str,
165    subtitle: &'static str,
166    effect: EffectKind,
167    accent: Color,
168    frames: Vec<Frame>,
169    quote: String,
170}
171
172#[derive(Clone, Copy, Debug)]
173struct StoryTask {
174    label: &'static str,
175    kind: LoaderKind,
176    total: u64,
177    offset: usize,
178    speed: usize,
179    rows: usize,
180    unit: &'static str,
181}
182
183fn compose_story(
184    layout: &Layout,
185    scenes: &[Scene],
186    scene_index: usize,
187    scene_tick: usize,
188    tick: usize,
189    scene_ticks: usize,
190    total_ticks: usize,
191    tasks: &[StoryTask],
192) -> Frame {
193    let scene = &scenes[scene_index];
194    let mut screen =
195        Frame::with_background(layout.width, layout.height, Some(Color::rgb(3, 6, 10)));
196    draw_story_background(&mut screen, tick, scene.accent);
197    draw_header(
198        &mut screen,
199        layout.header,
200        scene,
201        scene_index,
202        scenes.len(),
203        tick,
204    );
205    draw_stage(&mut screen, layout.stage, scene, scene_tick, scene_ticks);
206    draw_systems(
207        &mut screen,
208        layout.systems,
209        tasks,
210        tick,
211        total_ticks,
212        scene.accent,
213    );
214    draw_bottom_bus(
215        &mut screen,
216        layout.bottom,
217        scenes,
218        scene_index,
219        tick,
220        total_ticks,
221        scene.accent,
222    );
223    screen
224}
225
226fn draw_header(
227    screen: &mut Frame,
228    area: Rect,
229    scene: &Scene,
230    scene_index: usize,
231    total: usize,
232    tick: usize,
233) {
234    fill_rect(screen, area, Some(Color::rgb(5, 10, 16)));
235    for x in area.x..area.x + area.w {
236        let color = if (x + tick) % 9 == 0 {
237            scene.accent.brighten(0.25)
238        } else {
239            Color::rgb(35, 56, 72)
240        };
241        put_cell(
242            screen,
243            x,
244            area.y + area.h - 1,
245            '-',
246            color,
247            Some(Color::rgb(5, 10, 16)),
248            false,
249            true,
250        );
251    }
252    put_text(
253        screen,
254        area.x + 2,
255        area.y,
256        "KNOTT DYNAMICS // HUMANOID STORY RIG",
257        Color::rgb(255, 178, 78),
258        Some(Color::rgb(5, 10, 16)),
259        true,
260    );
261    put_text(
262        screen,
263        area.x + 2,
264        area.y + 1,
265        "tendons  leverage  motion  intelligence  field telemetry",
266        Color::rgb(126, 194, 255),
267        Some(Color::rgb(5, 10, 16)),
268        false,
269    );
270    let right = format!(
271        "ACT {:02}/{:02}  {}  EFFECT:{}  T+{:04}",
272        scene_index + 1,
273        total,
274        scene.title,
275        scene.effect.name(),
276        tick
277    );
278    put_text_right(
279        screen,
280        area.x + area.w.saturating_sub(2),
281        area.y,
282        &right,
283        scene.accent.brighten(0.2),
284        Some(Color::rgb(5, 10, 16)),
285        true,
286    );
287    put_text_right(
288        screen,
289        area.x + area.w.saturating_sub(2),
290        area.y + 1,
291        scene.subtitle,
292        Color::rgb(184, 204, 220),
293        Some(Color::rgb(5, 10, 16)),
294        false,
295    );
296}
297
298fn draw_stage(
299    screen: &mut Frame,
300    area: Rect,
301    scene: &Scene,
302    scene_tick: usize,
303    scene_ticks: usize,
304) {
305    draw_panel(screen, area, "MANIFESTO PLAYBACK", scene.accent);
306    let title = format!("{} :: {}", scene.title, scene.subtitle);
307    put_text(
308        screen,
309        area.x + 2,
310        area.y + 2,
311        &title,
312        scene.accent.brighten(0.18),
313        Some(Color::rgb(7, 12, 18)),
314        true,
315    );
316    let progress = scene_tick as f32 / scene_ticks.max(1) as f32;
317    draw_progress_strip(
318        screen,
319        Rect {
320            x: area.x + 2,
321            y: area.y + 3,
322            w: area.w.saturating_sub(4),
323            h: 1,
324        },
325        progress,
326        scene.accent,
327        Color::rgb(42, 54, 64),
328    );
329    let effect_area = Rect {
330        x: area.x + 3,
331        y: area.y + 5,
332        w: area.w.saturating_sub(6),
333        h: area.h.saturating_sub(12).max(4),
334    };
335    let frame = &scene.frames[scene_tick.min(scene.frames.len().saturating_sub(1))];
336    draw_frame_centered(screen, effect_area, frame);
337    let quote_area = Rect {
338        x: area.x + 3,
339        y: area.y + area.h.saturating_sub(6),
340        w: area.w.saturating_sub(6),
341        h: 4,
342    };
343    draw_quote(screen, quote_area, &scene.quote, scene.accent);
344}
345
346fn draw_systems(
347    screen: &mut Frame,
348    area: Rect,
349    tasks: &[StoryTask],
350    tick: usize,
351    total_ticks: usize,
352    accent: Color,
353) {
354    draw_panel(screen, area, "FAKE DOWNLOADS // LAB SYSTEMS", accent);
355    let mut y = area.y + 2;
356    let inner_w = area.w.saturating_sub(4).max(1);
357    for task in tasks {
358        if y >= area.y + area.h.saturating_sub(1) {
359            break;
360        }
361        let cycle = total_ticks.max(1) / task.speed.max(1) + 24;
362        let current_tick = (tick + task.offset) % cycle.max(1);
363        let fraction = current_tick as f32 / cycle.max(1) as f32;
364        let current = (task.total as f32 * fraction).round() as u64;
365        let progress = LoaderProgress::from_counts(current.min(task.total), task.total);
366        let rows = task.rows.min(area.y + area.h - y - 1).max(1);
367        let loader = Loader::with_config(
368            task.kind,
369            LoaderConfig::default()
370                .with_size(inner_w, rows)
371                .with_label(task.label)
372                .with_unit(task.unit)
373                .with_fraction(matches!(
374                    task.kind,
375                    LoaderKind::Tqdm | LoaderKind::Download | LoaderKind::Upload
376                ))
377                .with_gradient(
378                    Gradient::new(
379                        vec![accent, Color::rgb(74, 224, 255), Color::rgb(255, 186, 74)],
380                        24,
381                    ),
382                    GradientDirection::Horizontal,
383                ),
384        );
385        let frame = loader.frame(tick + task.offset, progress);
386        draw_frame(screen, area.x + 2, y, &frame, inner_w, rows);
387        y += rows + 1;
388    }
389}
390
391fn draw_bottom_bus(
392    screen: &mut Frame,
393    area: Rect,
394    scenes: &[Scene],
395    scene_index: usize,
396    tick: usize,
397    total_ticks: usize,
398    accent: Color,
399) {
400    draw_panel(screen, area, "STORY BUS // TELEMETRY", accent);
401    let bus_w = area.w.saturating_sub(4).max(1);
402    let scene_count = scenes.len().max(1);
403    for (index, scene) in scenes.iter().enumerate() {
404        let x0 = area.x + 2 + index * bus_w / scene_count;
405        let x1 = area.x + 2 + (index + 1) * bus_w / scene_count;
406        let active = index == scene_index;
407        for x in x0..x1.min(area.x + area.w.saturating_sub(2)) {
408            put_cell(
409                screen,
410                x,
411                area.y + 2,
412                if active { '=' } else { '-' },
413                if active {
414                    scene.accent
415                } else {
416                    Color::rgb(58, 70, 82)
417                },
418                Some(Color::rgb(7, 12, 18)),
419                active,
420                !active,
421            );
422        }
423    }
424    let global = tick as f32 / total_ticks.max(1) as f32;
425    draw_progress_strip(
426        screen,
427        Rect {
428            x: area.x + 2,
429            y: area.y + 4,
430            w: bus_w,
431            h: 1,
432        },
433        global,
434        Color::rgb(255, 178, 76),
435        Color::rgb(42, 54, 64),
436    );
437    let logs = [
438        "pulling tendon atlas from nature://forearm",
439        "downloading leverage matrices into the gait lab",
440        "burning actuator constraints into motion plans",
441        "decrypting sensor noise into intention",
442        "simulating force lines through cable and joint",
443        "packing humanoid grace into repeatable frames",
444    ];
445    let log = logs[(tick / 18) % logs.len()];
446    put_text(
447        screen,
448        area.x + 2,
449        area.y + 5,
450        log,
451        Color::rgb(188, 210, 224),
452        Some(Color::rgb(7, 12, 18)),
453        false,
454    );
455    let pulse = Loader::with_config(
456        LoaderKind::Braided,
457        LoaderConfig::default()
458            .with_size(area.w.saturating_sub(4).max(1), 1)
459            .with_label("field bus")
460            .with_percent(false)
461            .with_gradient(
462                Gradient::new(vec![accent, Color::rgb(255, 92, 212)], 16),
463                GradientDirection::Horizontal,
464            ),
465    )
466    .frame(tick, global);
467    draw_frame(
468        screen,
469        area.x + 2,
470        area.y + area.h.saturating_sub(2),
471        &pulse,
472        area.w.saturating_sub(4),
473        1,
474    );
475}
476
477fn build_scenes(source: &str, layout: &Layout, scene_ticks: usize) -> Vec<Scene> {
478    let paragraphs = paragraphs(source);
479    let effect_w = layout.stage.w.saturating_sub(8).max(40);
480    let effect_h = layout.stage.h.saturating_sub(16).max(5).min(12);
481    let specs = [
482        (
483            "BOOT SEQUENCE",
484            "matrix rain resolves the mission",
485            EffectKind::Matrix,
486            Color::rgb(74, 255, 170),
487            0usize,
488        ),
489        (
490            "FORGE",
491            "burning the actuator constraints",
492            EffectKind::Burn,
493            Color::rgb(255, 118, 54),
494            3,
495        ),
496        (
497            "STORM SENSORS",
498            "signal and response in the weather",
499            EffectKind::Thunderstorm,
500            Color::rgb(96, 204, 255),
501            2,
502        ),
503        (
504            "LASER ETCH",
505            "leverage maps cut into the frame",
506            EffectKind::LaserEtch,
507            Color::rgb(255, 220, 92),
508            7,
509        ),
510        (
511            "GRAVITY WELL",
512            "weight and balance collapse into order",
513            EffectKind::Blackhole,
514            Color::rgb(164, 126, 255),
515            5,
516        ),
517        (
518            "ORBITAL BALANCE",
519            "rings of motion settle around intent",
520            EffectKind::Rings,
521            Color::rgb(110, 255, 224),
522            6,
523        ),
524        (
525            "SWARM CALIBRATION",
526            "many small forces become one gait",
527            EffectKind::Swarm,
528            Color::rgb(255, 112, 218),
529            4,
530        ),
531        (
532            "FIELD DEMO",
533            "fireworks over the completed machine",
534            EffectKind::Fireworks,
535            Color::rgb(255, 180, 72),
536            1,
537        ),
538    ];
539    specs
540        .iter()
541        .enumerate()
542        .map(
543            |(index, (title, subtitle, kind, accent, paragraph_index))| {
544                let paragraph = paragraphs
545                    .get(*paragraph_index)
546                    .or_else(|| paragraphs.get(index % paragraphs.len().max(1)))
547                    .cloned()
548                    .unwrap_or_else(|| source.to_string());
549                let extra = match kind {
550                    EffectKind::Matrix => "Data falls until it becomes purpose.",
551                    EffectKind::Burn => "The forge removes everything that does not serve motion.",
552                    EffectKind::Thunderstorm => "Noise becomes weather; weather becomes signal.",
553                    EffectKind::LaserEtch => "Leverage is written as geometry and timing.",
554                    EffectKind::Blackhole => {
555                        "Weight collapses into balance before it expands into gait."
556                    }
557                    EffectKind::Rings => "Joints orbit a center of intention.",
558                    EffectKind::Swarm => "Many corrections gather into one quiet step.",
559                    _ => "The machine disappears into the grace of the whole.",
560                };
561                let text = wrap_text(&format!("{}\n\n{}", paragraph, extra), effect_w);
562                let gradient = Gradient::new(
563                    vec![*accent, accent.brighten(0.36), Color::rgb(235, 244, 250)],
564                    32,
565                );
566                let config = EffectConfig::default()
567                    .with_duration(scene_ticks.max(8))
568                    .with_seed(700 + index as u64 * 97)
569                    .with_canvas_size(effect_w, effect_h)
570                    .with_gradient(gradient, GradientDirection::Diagonal);
571                let frames = Effect::with_config(*kind, text.clone(), config).frames();
572                Scene {
573                    title,
574                    subtitle,
575                    effect: *kind,
576                    accent: *accent,
577                    frames,
578                    quote: paragraph,
579                }
580            },
581        )
582        .collect()
583}
584
585fn story_tasks() -> Vec<StoryTask> {
586    vec![
587        StoryTask {
588            label: "tendon_atlas.tar",
589            kind: LoaderKind::Tqdm,
590            total: 930,
591            offset: 0,
592            speed: 2,
593            rows: 1,
594            unit: "MB",
595        },
596        StoryTask {
597            label: "decrypt_gait_kernel",
598            kind: LoaderKind::Decrypt,
599            total: 512,
600            offset: 11,
601            speed: 3,
602            rows: 4,
603            unit: "blk",
604        },
605        StoryTask {
606            label: "forge_actuator_set",
607            kind: LoaderKind::Burn,
608            total: 740,
609            offset: 23,
610            speed: 3,
611            rows: 4,
612            unit: "N",
613        },
614        StoryTask {
615            label: "storm_sensor_mesh",
616            kind: LoaderKind::Thunderstorm,
617            total: 384,
618            offset: 37,
619            speed: 4,
620            rows: 4,
621            unit: "pkt",
622        },
623        StoryTask {
624            label: "laseretch_chassis",
625            kind: LoaderKind::LaserEtch,
626            total: 622,
627            offset: 53,
628            speed: 3,
629            rows: 4,
630            unit: "mm",
631        },
632        StoryTask {
633            label: "balance_rings",
634            kind: LoaderKind::Rings,
635            total: 480,
636            offset: 67,
637            speed: 5,
638            rows: 4,
639            unit: "sim",
640        },
641        StoryTask {
642            label: "swarm_corrections",
643            kind: LoaderKind::Swarm,
644            total: 2048,
645            offset: 79,
646            speed: 4,
647            rows: 4,
648            unit: "vec",
649        },
650        StoryTask {
651            label: "upload_field_manifest",
652            kind: LoaderKind::Upload,
653            total: 128,
654            offset: 91,
655            speed: 2,
656            rows: 1,
657            unit: "MB",
658        },
659    ]
660}
661
662fn load_knott_text() -> String {
663    let mut capture = false;
664    let mut lines = Vec::new();
665    for line in TEST_SH.lines() {
666        if capture {
667            if line.trim() == TEXT_MARKER {
668                break;
669            }
670            lines.push(line);
671        } else if line.contains(TEXT_MARKER) && line.contains("cat <<") {
672            capture = true;
673        }
674    }
675    lines.join("\n")
676}
677
678fn paragraphs(input: &str) -> Vec<String> {
679    input
680        .split("\n\n")
681        .map(|paragraph| {
682            paragraph
683                .lines()
684                .map(str::trim)
685                .collect::<Vec<_>>()
686                .join(" ")
687        })
688        .map(|paragraph| paragraph.trim().to_string())
689        .filter(|paragraph| !paragraph.is_empty())
690        .collect()
691}
692
693fn wrap_text(input: &str, width: usize) -> String {
694    let mut out = Vec::new();
695    for paragraph in input.split('\n') {
696        if paragraph.trim().is_empty() {
697            out.push(String::new());
698            continue;
699        }
700        let mut line = String::new();
701        for word in paragraph.split_whitespace() {
702            if !line.is_empty() && line.len() + word.len() + 1 > width.max(12) {
703                out.push(line);
704                line = String::new();
705            }
706            if !line.is_empty() {
707                line.push(' ');
708            }
709            line.push_str(word);
710        }
711        if !line.is_empty() {
712            out.push(line);
713        }
714    }
715    out.join("\n")
716}
717
718fn draw_story_background(screen: &mut Frame, tick: usize, accent: Color) {
719    let width = screen.width();
720    let height = screen.height();
721    for y in 0..height {
722        for x in 0..width {
723            let n = noise(0x5107, x as u64, y as u64, (tick / 3) as u64);
724            if n > 0.965 {
725                let ch = if n > 0.985 { '1' } else { '0' };
726                put_cell(
727                    screen,
728                    x,
729                    y,
730                    ch,
731                    accent.dim(0.42),
732                    Some(Color::rgb(3, 6, 10)),
733                    false,
734                    true,
735                );
736            } else if (x + y + tick / 2) % 41 == 0 {
737                put_cell(
738                    screen,
739                    x,
740                    y,
741                    '.',
742                    Color::rgb(34, 50, 62),
743                    Some(Color::rgb(3, 6, 10)),
744                    false,
745                    true,
746                );
747            }
748        }
749    }
750}
751
752fn draw_panel(screen: &mut Frame, area: Rect, title: &str, accent: Color) {
753    fill_rect(screen, area, Some(Color::rgb(7, 12, 18)));
754    if area.w < 2 || area.h < 2 {
755        return;
756    }
757    let border = Color::rgb(46, 70, 88);
758    for x in area.x..area.x + area.w {
759        put_cell(
760            screen,
761            x,
762            area.y,
763            '-',
764            border,
765            Some(Color::rgb(7, 12, 18)),
766            false,
767            true,
768        );
769        put_cell(
770            screen,
771            x,
772            area.y + area.h - 1,
773            '-',
774            border,
775            Some(Color::rgb(7, 12, 18)),
776            false,
777            true,
778        );
779    }
780    for y in area.y..area.y + area.h {
781        put_cell(
782            screen,
783            area.x,
784            y,
785            '|',
786            border,
787            Some(Color::rgb(7, 12, 18)),
788            false,
789            true,
790        );
791        put_cell(
792            screen,
793            area.x + area.w - 1,
794            y,
795            '|',
796            border,
797            Some(Color::rgb(7, 12, 18)),
798            false,
799            true,
800        );
801    }
802    put_cell(
803        screen,
804        area.x,
805        area.y,
806        '+',
807        accent,
808        Some(Color::rgb(7, 12, 18)),
809        true,
810        false,
811    );
812    put_cell(
813        screen,
814        area.x + area.w - 1,
815        area.y,
816        '+',
817        accent,
818        Some(Color::rgb(7, 12, 18)),
819        true,
820        false,
821    );
822    put_cell(
823        screen,
824        area.x,
825        area.y + area.h - 1,
826        '+',
827        accent,
828        Some(Color::rgb(7, 12, 18)),
829        true,
830        false,
831    );
832    put_cell(
833        screen,
834        area.x + area.w - 1,
835        area.y + area.h - 1,
836        '+',
837        accent,
838        Some(Color::rgb(7, 12, 18)),
839        true,
840        false,
841    );
842    put_text(
843        screen,
844        area.x + 2,
845        area.y,
846        title,
847        accent.brighten(0.12),
848        Some(Color::rgb(7, 12, 18)),
849        true,
850    );
851}
852
853fn fill_rect(screen: &mut Frame, area: Rect, bg: Option<Color>) {
854    for y in area.y..(area.y + area.h).min(screen.height()) {
855        for x in area.x..(area.x + area.w).min(screen.width()) {
856            let mut cell = Cell::new(' ');
857            cell.colors.bg = bg;
858            screen.set(x, y, cell);
859        }
860    }
861}
862
863fn draw_quote(screen: &mut Frame, area: Rect, quote: &str, accent: Color) {
864    let wrapped = wrap_text(quote, area.w);
865    put_text(
866        screen,
867        area.x,
868        area.y,
869        "SOURCE FROM test.sh:",
870        accent,
871        Some(Color::rgb(7, 12, 18)),
872        true,
873    );
874    for (line_index, line) in wrapped.lines().take(area.h.saturating_sub(1)).enumerate() {
875        put_text(
876            screen,
877            area.x,
878            area.y + line_index + 1,
879            line,
880            Color::rgb(186, 204, 214),
881            Some(Color::rgb(7, 12, 18)),
882            false,
883        );
884    }
885}
886
887fn draw_frame_centered(screen: &mut Frame, area: Rect, frame: &Frame) {
888    let x = area.x + area.w.saturating_sub(frame.width()) / 2;
889    let y = area.y + area.h.saturating_sub(frame.height()) / 2;
890    draw_frame(screen, x, y, frame, area.w, area.h);
891}
892
893fn draw_frame(screen: &mut Frame, x: usize, y: usize, frame: &Frame, max_w: usize, max_h: usize) {
894    for fy in 0..frame.height().min(max_h) {
895        for fx in 0..frame.width().min(max_w) {
896            if let Some(cell) = frame.cell(fx, fy) {
897                if cell.ch != ' ' || cell.has_style() {
898                    screen.set(x + fx, y + fy, cell.clone());
899                }
900            }
901        }
902    }
903}
904
905fn draw_progress_strip(screen: &mut Frame, area: Rect, ratio: f32, fill: Color, empty: Color) {
906    let active = (ratio.clamp(0.0, 1.0) * area.w as f32).round() as usize;
907    for x in 0..area.w {
908        let done = x < active;
909        put_cell(
910            screen,
911            area.x + x,
912            area.y,
913            if done { '#' } else { '-' },
914            if done { fill } else { empty },
915            Some(Color::rgb(7, 12, 18)),
916            done,
917            !done,
918        );
919    }
920}
921
922fn put_text(
923    screen: &mut Frame,
924    x: usize,
925    y: usize,
926    text: &str,
927    fg: Color,
928    bg: Option<Color>,
929    bold: bool,
930) {
931    if y >= screen.height() {
932        return;
933    }
934    for (offset, ch) in text.chars().enumerate() {
935        let px = x + offset;
936        if px >= screen.width() {
937            break;
938        }
939        put_cell(screen, px, y, ch, fg, bg, bold, false);
940    }
941}
942
943fn put_text_right(
944    screen: &mut Frame,
945    right_x: usize,
946    y: usize,
947    text: &str,
948    fg: Color,
949    bg: Option<Color>,
950    bold: bool,
951) {
952    let width = text.chars().count();
953    let x = right_x.saturating_sub(width);
954    put_text(screen, x, y, text, fg, bg, bold);
955}
956
957fn put_cell(
958    screen: &mut Frame,
959    x: usize,
960    y: usize,
961    ch: char,
962    fg: Color,
963    bg: Option<Color>,
964    bold: bool,
965    dim: bool,
966) {
967    if x >= screen.width() || y >= screen.height() {
968        return;
969    }
970    let mut cell = Cell::new(ch);
971    cell.colors.fg = Some(fg);
972    cell.colors.bg = bg;
973    cell.bold = bold;
974    cell.dim = dim;
975    screen.set(x, y, cell);
976}
977
978fn noise(seed: u64, a: u64, b: u64, c: u64) -> f32 {
979    let mut x = seed
980        ^ a.wrapping_mul(0x9e37_79b9_7f4a_7c15)
981        ^ b.wrapping_mul(0xbf58_476d_1ce4_e5b9)
982        ^ c.wrapping_mul(0x94d0_49bb_1331_11eb);
983    x ^= x >> 30;
984    x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9);
985    x ^= x >> 27;
986    x = x.wrapping_mul(0x94d0_49bb_1331_11eb);
987    let value = (x ^ (x >> 31)) >> 40;
988    value as f32 / ((1u64 << 24) - 1) as f32
989}
990
991fn terminal_size() -> (usize, usize) {
992    let env_size = env::var("COLUMNS")
993        .ok()
994        .and_then(|columns| columns.parse::<usize>().ok())
995        .zip(
996            env::var("LINES")
997                .ok()
998                .and_then(|lines| lines.parse::<usize>().ok()),
999        );
1000    if let Some((columns, lines)) = env_size {
1001        return (columns, lines);
1002    }
1003    let output = Command::new("stty").arg("size").output();
1004    if let Ok(output) = output {
1005        if output.status.success() {
1006            if let Ok(text) = String::from_utf8(output.stdout) {
1007                let mut parts = text.split_whitespace();
1008                if let (Some(lines), Some(columns)) = (parts.next(), parts.next()) {
1009                    if let (Ok(lines), Ok(columns)) =
1010                        (lines.parse::<usize>(), columns.parse::<usize>())
1011                    {
1012                        return (columns, lines);
1013                    }
1014                }
1015            }
1016        }
1017    }
1018    (132, 44)
1019}
1020
1021fn usage() {
1022    println!("Usage:");
1023    println!("  cargo run --example story -- [--seconds N] [--fps N] [--scene-seconds N] [--once]");
1024    println!();
1025    println!("Examples:");
1026    println!("  ./story.sh");
1027    println!("  ./story.sh --seconds 90 --fps 12");
1028    println!("  ./story.sh --once --no-alt");
1029}
1030
1031struct TerminalGuard {
1032    active: bool,
1033}
1034
1035impl TerminalGuard {
1036    fn enter(stdout: &mut io::Stdout, alt_screen: bool) -> Self {
1037        if alt_screen {
1038            write!(stdout, "\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H").unwrap();
1039        } else {
1040            write!(stdout, "\x1b[?25l\x1b[2J\x1b[H").unwrap();
1041        }
1042        stdout.flush().unwrap();
1043        Self { active: alt_screen }
1044    }
1045}
1046
1047impl Drop for TerminalGuard {
1048    fn drop(&mut self) {
1049        let mut stdout = io::stdout();
1050        if self.active {
1051            write!(stdout, "\x1b[0m\x1b[?25h\x1b[?1049l").unwrap();
1052        } else {
1053            write!(stdout, "\x1b[0m\x1b[?25h\n").unwrap();
1054        }
1055        stdout.flush().unwrap();
1056    }
1057}