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