Skip to main content

loader_lab/
loader_lab.rs

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}