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}