1use std::env;
2use std::io::{self, Read, 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
12fn main() {
13 let args = Args::parse();
14 let (terminal_width, terminal_height) = terminal_size();
15 let width = terminal_width.max(118);
16 let catalog_rows = LoaderKind::all().len().div_ceil((width / 11).max(1));
17 let height = terminal_height.max(6 + 3 + catalog_rows + 2 + 18);
18 let fps = args.fps.max(1);
19 let focus_ticks = (args.focus_seconds.max(1) * fps) as usize;
20 let total_ticks = args.seconds.map(|seconds| (seconds * fps) as usize);
21 let layout = Layout::new(width, height, LoaderKind::all().len());
22 let panes = build_panes(
23 layout.hero_inner_width(),
24 layout.hero_banner_height(),
25 focus_ticks.max(8),
26 );
27 let mut focus = args
28 .focus
29 .unwrap_or(1)
30 .saturating_sub(1)
31 .min(panes.len() - 1);
32 let mut manual_focus = args.focus.is_some();
33 let frame_delay = Duration::from_millis((1000 / fps) as u64);
34 let mut stdout = io::stdout();
35 let mut guard = TerminalGuard::enter(&mut stdout, args.alt_screen);
36 let mut tick = 0usize;
37
38 loop {
39 if let Some(input) = guard.poll_input() {
40 match input {
41 Input::Next => {
42 focus = (focus + 1) % panes.len();
43 manual_focus = true;
44 }
45 Input::Prev => {
46 focus = (focus + panes.len() - 1) % panes.len();
47 manual_focus = true;
48 }
49 Input::Auto => manual_focus = false,
50 Input::Quit => break,
51 }
52 }
53
54 let auto_focus = (tick / focus_ticks) % panes.len();
55 let active = if manual_focus { focus } else { auto_focus };
56 let frame = compose_lab(
57 &layout,
58 &panes,
59 active,
60 tick,
61 focus_ticks,
62 args.progress_seconds.max(4),
63 manual_focus,
64 );
65 write!(stdout, "\x1b[H{}\x1b[0m", frame.to_ansi_string()).unwrap();
66 stdout.flush().unwrap();
67
68 tick += 1;
69 if args.once || total_ticks.is_some_and(|limit| tick >= limit) {
70 break;
71 }
72 thread::sleep(frame_delay);
73 }
74}
75
76#[derive(Clone, Debug)]
77struct Args {
78 seconds: Option<u64>,
79 fps: u64,
80 focus_seconds: u64,
81 progress_seconds: u64,
82 focus: Option<usize>,
83 once: bool,
84 alt_screen: bool,
85}
86
87impl Args {
88 fn parse() -> Self {
89 let mut args = env::args().skip(1);
90 let mut parsed = Self {
91 seconds: Some(120),
92 fps: 10,
93 focus_seconds: 7,
94 progress_seconds: 28,
95 focus: None,
96 once: false,
97 alt_screen: true,
98 };
99 while let Some(arg) = args.next() {
100 match arg.as_str() {
101 "--seconds" => parsed.seconds = args.next().and_then(|value| value.parse().ok()),
102 "--fps" => {
103 parsed.fps = args
104 .next()
105 .and_then(|value| value.parse().ok())
106 .unwrap_or(parsed.fps)
107 }
108 "--focus-seconds" | "--page-seconds" => {
109 parsed.focus_seconds = args
110 .next()
111 .and_then(|value| value.parse().ok())
112 .unwrap_or(parsed.focus_seconds)
113 }
114 "--progress-seconds" => {
115 parsed.progress_seconds = args
116 .next()
117 .and_then(|value| value.parse().ok())
118 .unwrap_or(parsed.progress_seconds)
119 }
120 "--focus" | "--page" => {
121 parsed.focus = args.next().and_then(|value| value.parse().ok())
122 }
123 "--forever" => parsed.seconds = None,
124 "--once" => parsed.once = true,
125 "--no-alt" => parsed.alt_screen = false,
126 "-h" | "--help" | "help" => {
127 usage();
128 std::process::exit(0);
129 }
130 _ => {}
131 }
132 }
133 parsed
134 }
135}
136
137#[derive(Clone, Copy, Debug)]
138struct Rect {
139 x: usize,
140 y: usize,
141 w: usize,
142 h: usize,
143}
144
145#[derive(Clone, Debug)]
146struct Layout {
147 width: usize,
148 height: usize,
149 header: Rect,
150 hero: Rect,
151 lanes: Rect,
152 catalog: Rect,
153 footer: Rect,
154}
155
156impl Layout {
157 fn new(width: usize, height: usize, loader_count: usize) -> Self {
158 let header_h = 6;
159 let footer_h = 3;
160 let catalog_cols = (width / 11).max(1);
161 let catalog_rows = loader_count.div_ceil(catalog_cols).max(1);
162 let catalog_h = catalog_rows + 2;
163 let middle_h = height
164 .saturating_sub(header_h + catalog_h + footer_h)
165 .max(16);
166 let hero_w = ((width as f32) * 0.62).round() as usize;
167 let hero_w = hero_w.clamp(58, width.saturating_sub(32));
168 Self {
169 width,
170 height,
171 header: Rect {
172 x: 0,
173 y: 0,
174 w: width,
175 h: header_h,
176 },
177 hero: Rect {
178 x: 1,
179 y: header_h,
180 w: hero_w.saturating_sub(1),
181 h: middle_h,
182 },
183 lanes: Rect {
184 x: hero_w + 1,
185 y: header_h,
186 w: width.saturating_sub(hero_w + 2),
187 h: middle_h,
188 },
189 catalog: Rect {
190 x: 1,
191 y: header_h + middle_h,
192 w: width.saturating_sub(2),
193 h: catalog_h,
194 },
195 footer: Rect {
196 x: 0,
197 y: header_h + middle_h + catalog_h,
198 w: width,
199 h: footer_h,
200 },
201 }
202 }
203
204 fn hero_inner_width(&self) -> usize {
205 self.hero.w.saturating_sub(6).max(24)
206 }
207
208 fn hero_banner_height(&self) -> usize {
209 if self.hero.h >= 24 {
210 5
211 } else {
212 4
213 }
214 }
215}
216
217#[derive(Clone, Debug)]
218struct Pane {
219 index: usize,
220 kind: LoaderKind,
221 op: &'static str,
222 noun: &'static str,
223 label: String,
224 unit: &'static str,
225 total: u64,
226 speed: usize,
227 offset: usize,
228 accent: Color,
229 effect_kind: EffectKind,
230 effect_frames: Vec<Frame>,
231}
232
233fn compose_lab(
234 layout: &Layout,
235 panes: &[Pane],
236 active: usize,
237 tick: usize,
238 focus_ticks: usize,
239 progress_seconds: u64,
240 manual_focus: bool,
241) -> Frame {
242 let active_pane = &panes[active];
243 let mut screen =
244 Frame::with_background(layout.width, layout.height, Some(Color::rgb(3, 6, 11)));
245 draw_background(&mut screen, tick, active_pane.accent);
246 draw_header(
247 &mut screen,
248 layout.header,
249 active_pane,
250 active,
251 panes.len(),
252 tick,
253 manual_focus,
254 );
255 draw_hero(
256 &mut screen,
257 layout.hero,
258 active_pane,
259 tick,
260 focus_ticks,
261 progress_seconds,
262 );
263 draw_lanes(
264 &mut screen,
265 layout.lanes,
266 panes,
267 active,
268 tick,
269 progress_seconds,
270 );
271 draw_catalog(
272 &mut screen,
273 layout.catalog,
274 panes,
275 active,
276 tick,
277 progress_seconds,
278 );
279 draw_footer(
280 &mut screen,
281 layout.footer,
282 active,
283 panes.len(),
284 tick,
285 focus_ticks,
286 manual_focus,
287 active_pane.accent,
288 );
289 screen
290}
291
292fn draw_header(
293 screen: &mut Frame,
294 area: Rect,
295 active: &Pane,
296 active_index: usize,
297 total: usize,
298 tick: usize,
299 manual_focus: bool,
300) {
301 fill_rect(screen, area, Some(Color::rgb(5, 10, 16)));
302 put_text(
303 screen,
304 2,
305 0,
306 "KNOTT DYNAMICS // LOADER COMMAND DECK",
307 Color::rgb(255, 178, 72),
308 Some(Color::rgb(5, 10, 16)),
309 true,
310 );
311 let mode = if manual_focus {
312 "MANUAL FOCUS"
313 } else {
314 "AUTO FOCUS"
315 };
316 let right = format!(
317 "{:02}/{:02} {} LoaderKind::{} {}",
318 active_index + 1,
319 total,
320 mode,
321 active.kind.name(),
322 active.op
323 );
324 put_text_right(
325 screen,
326 area.w.saturating_sub(2),
327 0,
328 &right,
329 active.accent.brighten(0.18),
330 Some(Color::rgb(5, 10, 16)),
331 true,
332 );
333 let line = "one hero loader | six live transfer lanes | full catalog strip | arrows/h-l change focus | a auto | q quit";
334 put_text(
335 screen,
336 2,
337 1,
338 line,
339 Color::rgb(126, 194, 255),
340 Some(Color::rgb(5, 10, 16)),
341 false,
342 );
343 for x in area.x..area.x + area.w {
344 let sparkle = (x + tick) % 17 == 0;
345 put_cell(
346 screen,
347 x,
348 area.y + area.h - 1,
349 if sparkle { '*' } else { '-' },
350 if sparkle {
351 active.accent.brighten(0.28)
352 } else {
353 Color::rgb(42, 64, 82)
354 },
355 Some(Color::rgb(5, 10, 16)),
356 sparkle,
357 !sparkle,
358 );
359 }
360 let mini = format!(
361 "active mock {}: {} unit:{} rig:{} speed:{}x tick:{}",
362 active.op.to_ascii_lowercase(),
363 active.label,
364 active.unit,
365 active.effect_kind.name(),
366 active.speed,
367 tick
368 );
369 put_text(
370 screen,
371 2,
372 3,
373 &mini,
374 Color::rgb(186, 206, 218),
375 Some(Color::rgb(5, 10, 16)),
376 false,
377 );
378}
379
380fn draw_hero(
381 screen: &mut Frame,
382 area: Rect,
383 pane: &Pane,
384 tick: usize,
385 focus_ticks: usize,
386 progress_seconds: u64,
387) {
388 draw_panel(screen, area, "FOCUSED TRANSFER", pane.accent);
389 let inner = Rect {
390 x: area.x + 3,
391 y: area.y + 2,
392 w: area.w.saturating_sub(6).max(1),
393 h: area.h.saturating_sub(4).max(1),
394 };
395 let title = format!("{} // {} // {}", pane.kind.name(), pane.op, pane.noun);
396 put_text_clipped(
397 screen,
398 inner.x,
399 inner.y,
400 &title,
401 inner.w,
402 pane.accent.brighten(0.2),
403 Some(Color::rgb(7, 12, 18)),
404 true,
405 );
406 let stage_y = inner.y + 2;
407 let banner_h = pane
408 .effect_frames
409 .first()
410 .map(Frame::height)
411 .unwrap_or(3)
412 .min(5);
413 let banner = &pane.effect_frames[(tick + pane.offset) % pane.effect_frames.len().max(1)];
414 draw_frame(screen, inner.x, stage_y, banner, inner.w, banner_h);
415
416 let (progress, current, cycle) = transfer_progress(pane, tick, progress_seconds);
417 let metrics_y = stage_y + banner_h + 1;
418 let metrics = format!(
419 "{} / {}{} {:>3}% speed:{}x cycle:{} ticks",
420 current.min(pane.total),
421 pane.total,
422 pane.unit,
423 progress.percent(),
424 pane.speed,
425 cycle
426 );
427 put_text_clipped(
428 screen,
429 inner.x,
430 metrics_y,
431 &metrics,
432 inner.w,
433 Color::rgb(186, 206, 218),
434 Some(Color::rgb(7, 12, 18)),
435 false,
436 );
437 draw_progress_strip(
438 screen,
439 Rect {
440 x: inner.x,
441 y: metrics_y + 1,
442 w: inner.w,
443 h: 1,
444 },
445 progress.fraction(),
446 pane.accent,
447 Color::rgb(42, 54, 64),
448 );
449
450 let loader_y = metrics_y + 3;
451 let loader_h = inner.y + inner.h - loader_y;
452 if loader_h > 0 {
453 let loader = Loader::with_config(
454 pane.kind,
455 LoaderConfig::default()
456 .with_size(inner.w, loader_h)
457 .with_label(pane.label.clone())
458 .with_unit(pane.unit)
459 .with_fraction(true)
460 .with_gradient(
461 Gradient::new(
462 vec![
463 pane.accent,
464 pane.accent.brighten(0.38),
465 Color::rgb(90, 226, 255),
466 ],
467 28,
468 ),
469 GradientDirection::Horizontal,
470 ),
471 );
472 let frame = loader.frame(tick + pane.offset, progress);
473 draw_frame(screen, inner.x, loader_y, &frame, inner.w, loader_h);
474 }
475
476 let focus_ratio = (tick % focus_ticks.max(1)) as f32 / focus_ticks.max(1) as f32;
477 draw_progress_strip(
478 screen,
479 Rect {
480 x: inner.x,
481 y: area.y + area.h - 2,
482 w: inner.w,
483 h: 1,
484 },
485 focus_ratio,
486 Color::rgb(255, 178, 72),
487 Color::rgb(42, 54, 64),
488 );
489}
490
491fn draw_lanes(
492 screen: &mut Frame,
493 area: Rect,
494 panes: &[Pane],
495 active: usize,
496 tick: usize,
497 progress_seconds: u64,
498) {
499 draw_panel(screen, area, "LIVE LANES", Color::rgb(92, 204, 255));
500 let count = lane_count(area.h);
501 let lane_h = area.h.saturating_sub(2).max(1) / count.max(1);
502 for lane in 0..count {
503 let index = (active + lane + 1) % panes.len();
504 let pane = &panes[index];
505 let y = area.y + 2 + lane * lane_h;
506 let rect = Rect {
507 x: area.x + 2,
508 y,
509 w: area.w.saturating_sub(4).max(1),
510 h: lane_h.saturating_sub(1).max(1),
511 };
512 draw_lane(screen, rect, pane, tick, progress_seconds);
513 }
514}
515
516fn draw_lane(screen: &mut Frame, area: Rect, pane: &Pane, tick: usize, progress_seconds: u64) {
517 if area.h == 0 || area.w == 0 {
518 return;
519 }
520 let (progress, current, _) = transfer_progress(pane, tick, progress_seconds);
521 let title = format!("{:02} {} {}", pane.index + 1, pane.kind.name(), pane.op);
522 put_text_clipped(
523 screen,
524 area.x,
525 area.y,
526 &title,
527 area.w,
528 pane.accent,
529 Some(Color::rgb(7, 12, 18)),
530 true,
531 );
532 if area.h > 1 {
533 let metrics = format!(
534 "{} {} / {}{} {}x",
535 pane.label,
536 current.min(pane.total),
537 pane.total,
538 pane.unit,
539 pane.speed
540 );
541 put_text_clipped(
542 screen,
543 area.x,
544 area.y + 1,
545 &metrics,
546 area.w,
547 Color::rgb(180, 198, 210),
548 Some(Color::rgb(7, 12, 18)),
549 false,
550 );
551 }
552 if area.h > 2 {
553 let loader_h = area.h - 2;
554 let loader = Loader::with_config(
555 pane.kind,
556 LoaderConfig::default()
557 .with_size(area.w, loader_h)
558 .with_label(pane.label.clone())
559 .with_unit(pane.unit)
560 .with_percent(true)
561 .with_fraction(false)
562 .with_gradient(
563 Gradient::new(vec![pane.accent, Color::rgb(90, 226, 255)], 18),
564 GradientDirection::Horizontal,
565 ),
566 );
567 let frame = loader.frame(tick + pane.offset, progress);
568 draw_frame(screen, area.x, area.y + 2, &frame, area.w, loader_h);
569 }
570}
571
572fn draw_catalog(
573 screen: &mut Frame,
574 area: Rect,
575 panes: &[Pane],
576 active: usize,
577 tick: usize,
578 progress_seconds: u64,
579) {
580 draw_panel(
581 screen,
582 area,
583 "ALL LOADERS - VISIBLE CATALOG",
584 Color::rgb(255, 178, 72),
585 );
586 let cell_w = 11usize;
587 let cols = (area.w.saturating_sub(4) / cell_w).max(1);
588 for (index, pane) in panes.iter().enumerate() {
589 let col = index % cols;
590 let row = index / cols;
591 let x = area.x + 2 + col * cell_w;
592 let y = area.y + 2 + row;
593 if y >= area.y + area.h.saturating_sub(1) {
594 break;
595 }
596 let (progress, _, _) = transfer_progress(pane, tick, progress_seconds);
597 let marker = match (progress.percent() / 12).min(8) {
598 0 => '.',
599 1 | 2 => ':',
600 3 | 4 => '=',
601 5 | 6 => '#',
602 _ => '*',
603 };
604 let mut text = format!("{}{}", marker, pane.kind.name());
605 text.truncate(cell_w.saturating_sub(1));
606 let in_lanes = (1..=lane_count(999)).any(|offset| (active + offset) % panes.len() == index);
607 let fg = if index == active {
608 pane.accent.brighten(0.25)
609 } else if in_lanes {
610 Color::rgb(126, 194, 255)
611 } else {
612 Color::rgb(122, 140, 154)
613 };
614 put_text_clipped(
615 screen,
616 x,
617 y,
618 &text,
619 cell_w.saturating_sub(1),
620 fg,
621 Some(Color::rgb(7, 12, 18)),
622 index == active,
623 );
624 }
625}
626
627fn draw_footer(
628 screen: &mut Frame,
629 area: Rect,
630 active: usize,
631 total: usize,
632 tick: usize,
633 focus_ticks: usize,
634 manual_focus: bool,
635 accent: Color,
636) {
637 fill_rect(screen, area, Some(Color::rgb(5, 10, 16)));
638 for x in area.x..area.x + area.w {
639 put_cell(
640 screen,
641 x,
642 area.y,
643 '-',
644 Color::rgb(46, 70, 88),
645 Some(Color::rgb(5, 10, 16)),
646 false,
647 true,
648 );
649 }
650 put_text(
651 screen,
652 area.x + 2,
653 area.y + 1,
654 "<<<",
655 accent,
656 Some(Color::rgb(5, 10, 16)),
657 true,
658 );
659 put_text_right(
660 screen,
661 area.x + area.w.saturating_sub(3),
662 area.y + 1,
663 ">>>",
664 accent,
665 Some(Color::rgb(5, 10, 16)),
666 true,
667 );
668 let mode = if manual_focus { "MANUAL" } else { "AUTO" };
669 let text = format!(
670 "focus {:02}/{:02} mode:{} arrows/h-l next loader a auto q quit",
671 active + 1,
672 total,
673 mode
674 );
675 put_text(
676 screen,
677 area.x + 8,
678 area.y + 1,
679 &text,
680 Color::rgb(186, 206, 218),
681 Some(Color::rgb(5, 10, 16)),
682 false,
683 );
684 let ratio = (tick % focus_ticks.max(1)) as f32 / focus_ticks.max(1) as f32;
685 draw_progress_strip(
686 screen,
687 Rect {
688 x: area.x + 8,
689 y: area.y + 2,
690 w: area.w.saturating_sub(16),
691 h: 1,
692 },
693 ratio,
694 accent,
695 Color::rgb(42, 54, 64),
696 );
697}
698
699fn lane_count(height: usize) -> usize {
700 if height >= 34 {
701 6
702 } else if height >= 25 {
703 5
704 } else if height >= 17 {
705 4
706 } else {
707 3
708 }
709}
710
711fn transfer_progress(
712 pane: &Pane,
713 tick: usize,
714 progress_seconds: u64,
715) -> (LoaderProgress, u64, usize) {
716 let cycle = ((progress_seconds as usize * 10) / pane.speed.max(1)).max(18);
717 let progress_tick = (tick + pane.offset) % cycle;
718 let fraction = progress_tick as f32 / cycle.saturating_sub(1).max(1) as f32;
719 let current = (pane.total as f32 * fraction).round() as u64;
720 (
721 LoaderProgress::from_counts(current.min(pane.total), pane.total),
722 current,
723 cycle,
724 )
725}
726
727fn build_panes(effect_width: usize, effect_height: usize, focus_ticks: usize) -> Vec<Pane> {
728 let ops = [
729 ("DOWNLOAD", "tendon-atlas", "MB"),
730 ("UPLOAD", "field-manifest", "MB"),
731 ("SYNC", "sensor-mesh", "pkt"),
732 ("VERIFY", "leverage-map", "chk"),
733 ("STREAM", "gait-vectors", "vec"),
734 ("FORGE", "actuator-curve", "N"),
735 ("DECRYPT", "intent-bus", "blk"),
736 ("MIRROR", "joint-state", "msg"),
737 ("PACK", "motion-plan", "ops"),
738 ("RELAY", "balance-loop", "hz"),
739 ("CALIBRATE", "cable-stack", "mm"),
740 ("ARCHIVE", "field-log", "MB"),
741 ];
742 let effects = [
743 EffectKind::Matrix,
744 EffectKind::Burn,
745 EffectKind::Decrypt,
746 EffectKind::Thunderstorm,
747 EffectKind::LaserEtch,
748 EffectKind::Blackhole,
749 EffectKind::Rings,
750 EffectKind::Swarm,
751 EffectKind::Fireworks,
752 EffectKind::Slice,
753 EffectKind::Waves,
754 EffectKind::Wipe,
755 ];
756 let palette = [
757 Color::rgb(74, 255, 170),
758 Color::rgb(255, 118, 54),
759 Color::rgb(92, 204, 255),
760 Color::rgb(255, 216, 92),
761 Color::rgb(172, 130, 255),
762 Color::rgb(255, 112, 218),
763 Color::rgb(112, 236, 255),
764 Color::rgb(255, 178, 72),
765 ];
766 let speeds = [1, 2, 3, 4, 5, 7, 9, 11];
767
768 LoaderKind::all()
769 .iter()
770 .enumerate()
771 .map(|(index, kind)| {
772 let (op, noun, unit) = ops[index % ops.len()];
773 let effect_kind = effects[(index * 5 + 3) % effects.len()];
774 let accent = palette[index % palette.len()];
775 let label = format!("{} {}", op.to_ascii_lowercase(), noun);
776 let effect_text = format!("{} {}\n{}", op, kind.name(), noun).to_ascii_uppercase();
777 let effect = Effect::with_config(
778 effect_kind,
779 effect_text,
780 EffectConfig::default()
781 .with_duration(focus_ticks)
782 .with_seed(991 + index as u64 * 37)
783 .with_canvas_size(effect_width, effect_height.max(1))
784 .with_gradient(
785 Gradient::new(
786 vec![accent, accent.brighten(0.32), Color::rgb(230, 244, 250)],
787 20,
788 ),
789 GradientDirection::Diagonal,
790 ),
791 );
792 Pane {
793 index,
794 kind: *kind,
795 op,
796 noun,
797 label,
798 unit,
799 total: 128 + ((index as u64 * 97) % 2048),
800 speed: speeds[index % speeds.len()],
801 offset: index * 17,
802 accent,
803 effect_kind,
804 effect_frames: effect.frames(),
805 }
806 })
807 .collect()
808}
809
810fn draw_background(screen: &mut Frame, tick: usize, accent: Color) {
811 let width = screen.width();
812 let height = screen.height();
813 for y in 0..height {
814 for x in 0..width {
815 let n = noise(0x10ad, x as u64, y as u64, (tick / 3) as u64);
816 if n > 0.982 {
817 let ch = if n > 0.991 { '1' } else { '0' };
818 put_cell(
819 screen,
820 x,
821 y,
822 ch,
823 accent.dim(0.42),
824 Some(Color::rgb(3, 6, 11)),
825 false,
826 true,
827 );
828 } else if (x + y + tick / 2) % 53 == 0 {
829 put_cell(
830 screen,
831 x,
832 y,
833 '.',
834 Color::rgb(34, 48, 60),
835 Some(Color::rgb(3, 6, 11)),
836 false,
837 true,
838 );
839 }
840 }
841 }
842}
843
844fn draw_panel(screen: &mut Frame, area: Rect, title: &str, accent: Color) {
845 fill_rect(screen, area, Some(Color::rgb(7, 12, 18)));
846 if area.w < 2 || area.h < 2 {
847 return;
848 }
849 let border = accent.dim(0.52);
850 for x in area.x..area.x + area.w {
851 put_cell(
852 screen,
853 x,
854 area.y,
855 '-',
856 border,
857 Some(Color::rgb(7, 12, 18)),
858 false,
859 true,
860 );
861 put_cell(
862 screen,
863 x,
864 area.y + area.h - 1,
865 '-',
866 border,
867 Some(Color::rgb(7, 12, 18)),
868 false,
869 true,
870 );
871 }
872 for y in area.y..area.y + area.h {
873 put_cell(
874 screen,
875 area.x,
876 y,
877 '|',
878 border,
879 Some(Color::rgb(7, 12, 18)),
880 false,
881 true,
882 );
883 put_cell(
884 screen,
885 area.x + area.w - 1,
886 y,
887 '|',
888 border,
889 Some(Color::rgb(7, 12, 18)),
890 false,
891 true,
892 );
893 }
894 put_cell(
895 screen,
896 area.x,
897 area.y,
898 '+',
899 accent,
900 Some(Color::rgb(7, 12, 18)),
901 true,
902 false,
903 );
904 put_cell(
905 screen,
906 area.x + area.w - 1,
907 area.y,
908 '+',
909 accent,
910 Some(Color::rgb(7, 12, 18)),
911 true,
912 false,
913 );
914 put_cell(
915 screen,
916 area.x,
917 area.y + area.h - 1,
918 '+',
919 accent,
920 Some(Color::rgb(7, 12, 18)),
921 true,
922 false,
923 );
924 put_cell(
925 screen,
926 area.x + area.w - 1,
927 area.y + area.h - 1,
928 '+',
929 accent,
930 Some(Color::rgb(7, 12, 18)),
931 true,
932 false,
933 );
934 put_text_clipped(
935 screen,
936 area.x + 2,
937 area.y,
938 title,
939 area.w.saturating_sub(4),
940 accent.brighten(0.12),
941 Some(Color::rgb(7, 12, 18)),
942 true,
943 );
944}
945
946fn draw_progress_strip(screen: &mut Frame, area: Rect, ratio: f32, fill: Color, empty: Color) {
947 let active = (ratio.clamp(0.0, 1.0) * area.w as f32).round() as usize;
948 for x in 0..area.w {
949 let done = x < active;
950 put_cell(
951 screen,
952 area.x + x,
953 area.y,
954 if done { '#' } else { '-' },
955 if done { fill } else { empty },
956 Some(Color::rgb(7, 12, 18)),
957 done,
958 !done,
959 );
960 }
961}
962
963fn fill_rect(screen: &mut Frame, area: Rect, bg: Option<Color>) {
964 for y in area.y..(area.y + area.h).min(screen.height()) {
965 for x in area.x..(area.x + area.w).min(screen.width()) {
966 let mut cell = Cell::new(' ');
967 cell.colors.bg = bg;
968 screen.set(x, y, cell);
969 }
970 }
971}
972
973fn draw_frame(screen: &mut Frame, x: usize, y: usize, frame: &Frame, max_w: usize, max_h: usize) {
974 for fy in 0..frame.height().min(max_h) {
975 for fx in 0..frame.width().min(max_w) {
976 if let Some(cell) = frame.cell(fx, fy) {
977 if cell.ch != ' ' || cell.has_style() {
978 screen.set(x + fx, y + fy, cell.clone());
979 }
980 }
981 }
982 }
983}
984
985fn put_text(
986 screen: &mut Frame,
987 x: usize,
988 y: usize,
989 text: &str,
990 fg: Color,
991 bg: Option<Color>,
992 bold: bool,
993) {
994 put_text_clipped(screen, x, y, text, usize::MAX, fg, bg, bold);
995}
996
997fn put_text_clipped(
998 screen: &mut Frame,
999 x: usize,
1000 y: usize,
1001 text: &str,
1002 max_width: usize,
1003 fg: Color,
1004 bg: Option<Color>,
1005 bold: bool,
1006) {
1007 if y >= screen.height() || max_width == 0 {
1008 return;
1009 }
1010 for (offset, ch) in text.chars().take(max_width).enumerate() {
1011 let px = x + offset;
1012 if px >= screen.width() {
1013 break;
1014 }
1015 put_cell(screen, px, y, ch, fg, bg, bold, false);
1016 }
1017}
1018
1019fn put_text_right(
1020 screen: &mut Frame,
1021 right_x: usize,
1022 y: usize,
1023 text: &str,
1024 fg: Color,
1025 bg: Option<Color>,
1026 bold: bool,
1027) {
1028 let x = right_x.saturating_sub(text.chars().count());
1029 put_text(screen, x, y, text, fg, bg, bold);
1030}
1031
1032fn put_cell(
1033 screen: &mut Frame,
1034 x: usize,
1035 y: usize,
1036 ch: char,
1037 fg: Color,
1038 bg: Option<Color>,
1039 bold: bool,
1040 dim: bool,
1041) {
1042 if x >= screen.width() || y >= screen.height() {
1043 return;
1044 }
1045 let mut cell = Cell::new(ch);
1046 cell.colors.fg = Some(fg);
1047 cell.colors.bg = bg;
1048 cell.bold = bold;
1049 cell.dim = dim;
1050 screen.set(x, y, cell);
1051}
1052
1053fn noise(seed: u64, a: u64, b: u64, c: u64) -> f32 {
1054 let mut x = seed
1055 ^ a.wrapping_mul(0x9e37_79b9_7f4a_7c15)
1056 ^ b.wrapping_mul(0xbf58_476d_1ce4_e5b9)
1057 ^ c.wrapping_mul(0x94d0_49bb_1331_11eb);
1058 x ^= x >> 30;
1059 x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9);
1060 x ^= x >> 27;
1061 x = x.wrapping_mul(0x94d0_49bb_1331_11eb);
1062 let value = (x ^ (x >> 31)) >> 40;
1063 value as f32 / ((1u64 << 24) - 1) as f32
1064}
1065
1066fn terminal_size() -> (usize, usize) {
1067 let env_size = env::var("COLUMNS")
1068 .ok()
1069 .and_then(|columns| columns.parse::<usize>().ok())
1070 .zip(
1071 env::var("LINES")
1072 .ok()
1073 .and_then(|lines| lines.parse::<usize>().ok()),
1074 );
1075 if let Some((columns, lines)) = env_size {
1076 return (columns, lines);
1077 }
1078 let output = Command::new("stty").arg("size").output();
1079 if let Ok(output) = output {
1080 if output.status.success() {
1081 if let Ok(text) = String::from_utf8(output.stdout) {
1082 let mut parts = text.split_whitespace();
1083 if let (Some(lines), Some(columns)) = (parts.next(), parts.next()) {
1084 if let (Ok(lines), Ok(columns)) =
1085 (lines.parse::<usize>(), columns.parse::<usize>())
1086 {
1087 return (columns, lines);
1088 }
1089 }
1090 }
1091 }
1092 }
1093 (132, 44)
1094}
1095
1096#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1097enum Input {
1098 Next,
1099 Prev,
1100 Auto,
1101 Quit,
1102}
1103
1104struct TerminalGuard {
1105 alt_screen: bool,
1106 stty_state: Option<String>,
1107}
1108
1109impl TerminalGuard {
1110 fn enter(stdout: &mut io::Stdout, alt_screen: bool) -> Self {
1111 if alt_screen {
1112 write!(stdout, "\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H").unwrap();
1113 } else {
1114 write!(stdout, "\x1b[?25l\x1b[2J\x1b[H").unwrap();
1115 }
1116 stdout.flush().unwrap();
1117 let stty_state = Command::new("stty")
1118 .arg("-g")
1119 .output()
1120 .ok()
1121 .filter(|output| output.status.success())
1122 .and_then(|output| String::from_utf8(output.stdout).ok())
1123 .map(|state| state.trim().to_string())
1124 .filter(|state| !state.is_empty());
1125 if stty_state.is_some() {
1126 let _ = Command::new("stty")
1127 .args(["raw", "-echo", "min", "0", "time", "0"])
1128 .status();
1129 }
1130 Self {
1131 alt_screen,
1132 stty_state,
1133 }
1134 }
1135
1136 fn poll_input(&mut self) -> Option<Input> {
1137 self.stty_state.as_ref()?;
1138 let mut buf = [0u8; 16];
1139 let count = io::stdin().read(&mut buf).ok()?;
1140 if count == 0 {
1141 return None;
1142 }
1143 let bytes = &buf[..count];
1144 for index in 0..bytes.len() {
1145 match bytes[index] {
1146 b'q' | b'Q' => return Some(Input::Quit),
1147 b'a' | b'A' => return Some(Input::Auto),
1148 b'l' | b'L' | b'n' | b'N' | b' ' => return Some(Input::Next),
1149 b'h' | b'H' | b'p' | b'P' => return Some(Input::Prev),
1150 0x1b if index + 2 < bytes.len() && bytes[index + 1] == b'[' => {
1151 if bytes[index + 2] == b'C' {
1152 return Some(Input::Next);
1153 }
1154 if bytes[index + 2] == b'D' {
1155 return Some(Input::Prev);
1156 }
1157 }
1158 _ => {}
1159 }
1160 }
1161 None
1162 }
1163}
1164
1165impl Drop for TerminalGuard {
1166 fn drop(&mut self) {
1167 if let Some(state) = &self.stty_state {
1168 let _ = Command::new("stty").arg(state).status();
1169 }
1170 let mut stdout = io::stdout();
1171 if self.alt_screen {
1172 write!(stdout, "\x1b[0m\x1b[?25h\x1b[?1049l").unwrap();
1173 } else {
1174 write!(stdout, "\x1b[0m\x1b[?25h\n").unwrap();
1175 }
1176 stdout.flush().unwrap();
1177 }
1178}
1179
1180fn usage() {
1181 println!("Usage:");
1182 println!(" ./loaders.sh [--seconds N] [--fps N] [--focus-seconds N] [--focus N]");
1183 println!();
1184 println!("Controls:");
1185 println!(" Right arrow / l / n / space: next focused loader");
1186 println!(" Left arrow / h / p: previous focused loader");
1187 println!(" a: resume auto focus");
1188 println!(" q: quit");
1189}