use std::env;
use std::fs;
use std::io::{self, Write};
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
use aisling::{Cell, Color, Effect, EffectConfig, EffectKind, Frame, Gradient, GradientDirection};
const TEXT_MARKER: &str = "__KNOTT_TEXT__";
fn main() {
let args = Args::parse();
let (terminal_width, terminal_height) = terminal_size();
let width = terminal_width.max(48);
let height = terminal_height.max(18);
let layout = Layout::new(width, height);
let text = args.text.unwrap_or_else(load_text_from_test_sh);
let snippets = snippets_for_effects(&text, layout.inner_width, layout.inner_height);
let theme = args.theme;
let panes = build_panes(
&snippets,
layout.inner_width,
layout.inner_height,
args.duration_frames,
theme,
);
let mut stdout = io::stdout();
let _guard = TerminalGuard::enter(&mut stdout);
let frame_delay = Duration::from_millis((1000 / args.fps.max(1)) as u64);
let page_ticks = (args.fps.max(1) * args.page_seconds.max(1)) as usize;
let max_ticks = args
.seconds
.map(|seconds| (seconds * args.fps.max(1)) as usize);
let start = Instant::now();
let mut tick = 0usize;
loop {
let page = if layout.panes_per_page == 0 {
0
} else {
(tick / page_ticks) % layout.page_count(panes.len())
};
let screen = compose_screen(&panes, &layout, page, tick, &text, start.elapsed(), theme);
write!(stdout, "\x1b[H").unwrap();
write_screen(&mut stdout, &screen, &layout).unwrap();
stdout.flush().unwrap();
tick += 1;
if args.once || max_ticks.is_some_and(|limit| tick >= limit) {
break;
}
thread::sleep(frame_delay);
}
}
#[derive(Clone, Debug)]
struct Args {
text: Option<String>,
seconds: Option<u64>,
fps: u64,
page_seconds: u64,
duration_frames: usize,
once: bool,
theme: Theme,
}
impl Args {
fn parse() -> Self {
let mut args = env::args().skip(1);
let mut parsed = Self {
text: None,
seconds: Some(45),
fps: 24,
page_seconds: 7,
duration_frames: 96,
once: false,
theme: Theme::Neon,
};
while let Some(arg) = args.next() {
match arg.as_str() {
"--text" => parsed.text = args.next(),
"--theme" => {
parsed.theme = args
.next()
.as_deref()
.and_then(Theme::parse)
.unwrap_or(parsed.theme);
}
"--industrial" | "--manly" => parsed.theme = Theme::Industrial,
"--neon" => parsed.theme = Theme::Neon,
"--seconds" => parsed.seconds = args.next().and_then(|value| value.parse().ok()),
"--fps" => {
parsed.fps = args
.next()
.and_then(|value| value.parse().ok())
.unwrap_or(parsed.fps)
}
"--page-seconds" => {
parsed.page_seconds = args
.next()
.and_then(|value| value.parse().ok())
.unwrap_or(parsed.page_seconds);
}
"--duration-frames" => {
parsed.duration_frames = args
.next()
.and_then(|value| value.parse().ok())
.unwrap_or(parsed.duration_frames);
}
"--forever" => parsed.seconds = None,
"--once" => parsed.once = true,
value => {
if parsed.text.is_none() {
parsed.text = Some(value.to_string());
}
}
}
}
parsed.duration_frames = parsed.duration_frames.max(16);
parsed
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Theme {
Neon,
Industrial,
}
impl Theme {
fn parse(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"neon" => Some(Self::Neon),
"industrial" | "steel" | "dark" | "manly" => Some(Self::Industrial),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Self::Neon => "neon",
Self::Industrial => "industrial",
}
}
fn screen_bg(self) -> Color {
match self {
Self::Neon => Color::rgb(4, 6, 13),
Self::Industrial => Color::rgb(10, 11, 10),
}
}
fn header_bg(self) -> Color {
match self {
Self::Neon => Color::rgb(8, 10, 24),
Self::Industrial => Color::rgb(18, 17, 14),
}
}
fn pane_bg(self) -> Color {
match self {
Self::Neon => Color::rgb(8, 11, 22),
Self::Industrial => Color::rgb(16, 17, 16),
}
}
fn title_color(self) -> Color {
match self {
Self::Neon => Color::rgb(0, 255, 210),
Self::Industrial => Color::rgb(216, 166, 72),
}
}
fn subtitle_color(self) -> Color {
match self {
Self::Neon => Color::rgb(255, 120, 220),
Self::Industrial => Color::rgb(150, 154, 145),
}
}
fn source_color(self) -> Color {
match self {
Self::Neon => Color::rgb(148, 180, 255),
Self::Industrial => Color::rgb(124, 134, 116),
}
}
fn footer_line(self) -> Color {
match self {
Self::Neon => Color::rgb(70, 80, 130),
Self::Industrial => Color::rgb(74, 69, 58),
}
}
fn footer_text(self) -> Color {
match self {
Self::Neon => Color::rgb(160, 170, 190),
Self::Industrial => Color::rgb(158, 146, 122),
}
}
fn base_cell(self) -> ScreenCell {
ScreenCell {
ch: ' ',
fg: Some(match self {
Self::Neon => Color::rgb(32, 36, 48),
Self::Industrial => Color::rgb(42, 42, 38),
}),
bg: Some(self.screen_bg()),
bold: false,
}
}
fn accent(self, index: usize) -> Color {
match self {
Self::Neon => neon_color(index),
Self::Industrial => industrial_color(index),
}
}
fn tune_cell_color(self, color: Color, accent: Color) -> Color {
match self {
Self::Neon => color,
Self::Industrial => color.dim(0.56).blend(accent, 0.42).brighten(0.04),
}
}
}
struct TerminalGuard;
impl TerminalGuard {
fn enter(stdout: &mut io::Stdout) -> Self {
write!(stdout, "\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H").unwrap();
stdout.flush().unwrap();
Self
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let mut stdout = io::stdout();
let _ = write!(stdout, "\x1b[0m\x1b[?25h\x1b[?1049l");
let _ = stdout.flush();
}
}
#[derive(Clone, Copy, Debug)]
struct Layout {
width: usize,
height: usize,
columns: usize,
rows: usize,
pane_width: usize,
pane_height: usize,
inner_width: usize,
inner_height: usize,
header_height: usize,
footer_height: usize,
panes_per_page: usize,
}
impl Layout {
fn new(width: usize, height: usize) -> Self {
let header_height = 4;
let footer_height = 2;
let usable_height = height.saturating_sub(header_height + footer_height).max(10);
let columns = match width {
0..=63 => 1,
64..=95 => 2,
96..=131 => 3,
132..=171 => 4,
_ => 5,
};
let rows = (usable_height / 8).clamp(2, 6);
let pane_width = (width / columns).max(14);
let pane_height = (usable_height / rows).max(7);
let inner_width = pane_width.saturating_sub(2).max(8);
let inner_height = pane_height.saturating_sub(2).max(3);
let panes_per_page = columns * rows;
Self {
width,
height,
columns,
rows,
pane_width,
pane_height,
inner_width,
inner_height,
header_height,
footer_height,
panes_per_page,
}
}
fn page_count(self, pane_count: usize) -> usize {
pane_count.div_ceil(self.panes_per_page.max(1)).max(1)
}
}
#[derive(Clone)]
struct PaneRuntime {
index: usize,
kind: EffectKind,
frames: Vec<Frame>,
offset: usize,
speed_numerator: usize,
accent: Color,
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct ScreenCell {
ch: char,
fg: Option<Color>,
bg: Option<Color>,
bold: bool,
}
impl Default for ScreenCell {
fn default() -> Self {
Self {
ch: ' ',
fg: Some(Color::rgb(32, 36, 48)),
bg: Some(Color::rgb(4, 6, 13)),
bold: false,
}
}
}
fn build_panes(
snippets: &[String],
inner_width: usize,
inner_height: usize,
base_duration: usize,
theme: Theme,
) -> Vec<PaneRuntime> {
EffectKind::all()
.iter()
.enumerate()
.map(|(index, kind)| {
let accent = theme.accent(index);
let duration = base_duration + (index % 9) * 5;
let config = EffectConfig::default()
.with_duration(duration)
.with_seed(1009 + index as u64 * 97)
.with_canvas_size(inner_width, inner_height)
.with_gradient(effect_gradient(index, theme), gradient_direction(index));
let frames = Effect::with_config(*kind, snippets[index].clone(), config).frames();
PaneRuntime {
index,
kind: *kind,
frames,
offset: index * 13,
speed_numerator: 1 + (index % 4),
accent,
}
})
.collect()
}
fn compose_screen(
panes: &[PaneRuntime],
layout: &Layout,
page: usize,
tick: usize,
text: &str,
elapsed: Duration,
theme: Theme,
) -> Vec<ScreenCell> {
let mut screen = vec![theme.base_cell(); layout.width * layout.height];
paint_header(&mut screen, layout, panes.len(), page, text, elapsed, theme);
let page_start = page * layout.panes_per_page;
let page_end = (page_start + layout.panes_per_page).min(panes.len());
for (slot, pane) in panes[page_start..page_end].iter().enumerate() {
let column = slot % layout.columns;
let row = slot / layout.columns;
let x = column * layout.pane_width;
let y = layout.header_height + row * layout.pane_height;
paint_pane(&mut screen, layout, x, y, pane, tick, theme);
}
paint_footer(&mut screen, layout, theme);
screen
}
fn paint_header(
screen: &mut [ScreenCell],
layout: &Layout,
effect_count: usize,
page: usize,
text: &str,
elapsed: Duration,
theme: Theme,
) {
let title = " AISLING // ALL EFFECTS MULTI-PANE SHOWCASE ";
let subtitle = format!(
" {} effects | {} theme | {}x{} panes | page {}/{} | source: test.sh | {:.1}s ",
effect_count,
theme.name(),
layout.columns,
layout.rows,
page + 1,
effect_count.div_ceil(layout.panes_per_page.max(1)),
elapsed.as_secs_f32()
);
let source = first_words(text, layout.width.saturating_sub(4));
for x in 0..layout.width {
let color = theme.accent(x / 7);
put(
screen,
layout,
x,
0,
'═',
Some(color),
Some(theme.header_bg()),
true,
);
put(
screen,
layout,
x,
3,
'─',
Some(color.dim(0.55)),
Some(theme.header_bg()),
false,
);
}
put_text_center(
screen,
layout,
1,
title,
Some(theme.title_color()),
Some(theme.header_bg()),
true,
);
put_text_center(
screen,
layout,
2,
&subtitle,
Some(theme.subtitle_color()),
Some(theme.header_bg()),
false,
);
put_text(
screen,
layout,
2,
3,
&source,
Some(theme.source_color()),
Some(theme.header_bg()),
false,
);
}
fn paint_footer(screen: &mut [ScreenCell], layout: &Layout, theme: Theme) {
let y = layout.height.saturating_sub(layout.footer_height);
for x in 0..layout.width {
put(
screen,
layout,
x,
y,
'─',
Some(theme.footer_line()),
Some(theme.screen_bg()),
false,
);
}
put_text_center(
screen,
layout,
y + 1,
" Ctrl-C exits | ./run.sh showcase --theme industrial --forever | staggered seeds, offsets, and speeds ",
Some(theme.footer_text()),
Some(theme.screen_bg()),
false,
);
}
fn paint_pane(
screen: &mut [ScreenCell],
layout: &Layout,
x: usize,
y: usize,
pane: &PaneRuntime,
tick: usize,
theme: Theme,
) {
let w = layout.pane_width.min(layout.width.saturating_sub(x));
let h = layout
.pane_height
.min(layout.height.saturating_sub(y + layout.footer_height));
if w < 4 || h < 4 || pane.frames.is_empty() {
return;
}
let accent = pane.accent;
let bg = Some(theme.pane_bg());
let dim = Some(accent.dim(0.48));
for dx in 0..w {
put(screen, layout, x + dx, y, '─', dim, bg, false);
put(screen, layout, x + dx, y + h - 1, '─', dim, bg, false);
}
for dy in 0..h {
put(screen, layout, x, y + dy, '│', dim, bg, false);
put(screen, layout, x + w - 1, y + dy, '│', dim, bg, false);
}
put(screen, layout, x, y, '╭', Some(accent), bg, true);
put(screen, layout, x + w - 1, y, '╮', Some(accent), bg, true);
put(screen, layout, x, y + h - 1, '╰', Some(accent), bg, true);
put(
screen,
layout,
x + w - 1,
y + h - 1,
'╯',
Some(accent),
bg,
true,
);
let title = format!(" {:02} {} ", pane.index + 1, pane.kind.name());
put_text(
screen,
layout,
x + 2,
y,
&truncate(&title, w.saturating_sub(4)),
Some(accent.brighten(0.25)),
bg,
true,
);
let frame_index = ((tick * pane.speed_numerator / 2) + pane.offset) % pane.frames.len();
let frame = &pane.frames[frame_index];
let pulse = (frame_index as f32 / pane.frames.len() as f32 * std::f32::consts::TAU)
.sin()
.abs();
let meter_width = w.saturating_sub(4);
let meter_fill = (meter_width as f32 * frame_index as f32 / pane.frames.len() as f32) as usize;
for i in 0..meter_width {
let ch = if i <= meter_fill { '━' } else { '·' };
let color = if i <= meter_fill {
accent.brighten(0.15 + pulse * 0.25)
} else {
accent.dim(0.25)
};
put(
screen,
layout,
x + 2 + i,
y + h - 1,
ch,
Some(color),
bg,
false,
);
}
for fy in 0..layout.inner_height.min(h.saturating_sub(2)) {
for fx in 0..layout.inner_width.min(w.saturating_sub(2)) {
if let Some(cell) = frame.cell(fx, fy) {
let target_x = x + 1 + fx;
let target_y = y + 1 + fy;
let mapped = map_cell(cell, pane.accent, theme);
put(
screen,
layout,
target_x,
target_y,
mapped.ch,
mapped.fg,
mapped.bg.or(bg),
mapped.bold,
);
}
}
}
}
fn map_cell(cell: &Cell, accent: Color, theme: Theme) -> ScreenCell {
let fg = cell
.colors
.fg
.map(|color| theme.tune_cell_color(color, accent))
.or(Some(accent));
ScreenCell {
ch: cell.ch,
fg,
bg: cell.colors.bg,
bold: cell.bold,
}
}
fn write_screen(stdout: &mut io::Stdout, screen: &[ScreenCell], layout: &Layout) -> io::Result<()> {
let width = layout.width;
let height = layout.height;
let mut active = ScreenCell {
ch: '\0',
fg: None,
bg: None,
bold: false,
};
for y in 0..height {
if y > 0 {
write!(stdout, "\x1b[0m\r\n")?;
active = ScreenCell {
ch: '\0',
fg: None,
bg: None,
bold: false,
};
}
for x in 0..width {
let cell = screen.get(y * width + x).copied().unwrap_or_default();
if same_style(active, cell) {
write!(stdout, "{}", cell.ch)?;
} else {
write!(stdout, "\x1b[0m{}{}", style_prefix(cell), cell.ch)?;
active = cell;
}
}
}
write!(stdout, "\x1b[0m")?;
Ok(())
}
fn same_style(a: ScreenCell, b: ScreenCell) -> bool {
a.fg == b.fg && a.bg == b.bg && a.bold == b.bold
}
fn style_prefix(cell: ScreenCell) -> String {
let mut parts = Vec::new();
if cell.bold {
parts.push("1".to_string());
}
if let Some(fg) = cell.fg {
parts.push(format!("38;2;{};{};{}", fg.r, fg.g, fg.b));
}
if let Some(bg) = cell.bg {
parts.push(format!("48;2;{};{};{}", bg.r, bg.g, bg.b));
}
if parts.is_empty() {
String::new()
} else {
format!("\x1b[{}m", parts.join(";"))
}
}
fn put(
screen: &mut [ScreenCell],
layout: &Layout,
x: usize,
y: usize,
ch: char,
fg: Option<Color>,
bg: Option<Color>,
bold: bool,
) {
if x < layout.width && y < layout.height {
screen[y * layout.width + x] = ScreenCell { ch, fg, bg, bold };
}
}
fn put_text(
screen: &mut [ScreenCell],
layout: &Layout,
x: usize,
y: usize,
text: &str,
fg: Option<Color>,
bg: Option<Color>,
bold: bool,
) {
for (offset, ch) in text.chars().enumerate() {
put(screen, layout, x + offset, y, ch, fg, bg, bold);
}
}
fn put_text_center(
screen: &mut [ScreenCell],
layout: &Layout,
y: usize,
text: &str,
fg: Option<Color>,
bg: Option<Color>,
bold: bool,
) {
let len = text.chars().count();
let x = layout.width.saturating_sub(len) / 2;
put_text(screen, layout, x, y, text, fg, bg, bold);
}
fn terminal_size() -> (usize, usize) {
if let Ok(output) = Command::new("stty").arg("size").output() {
if output.status.success() {
if let Ok(text) = String::from_utf8(output.stdout) {
let mut parts = text.split_whitespace();
if let (Some(rows), Some(columns)) = (parts.next(), parts.next()) {
if let (Ok(rows), Ok(columns)) =
(rows.parse::<usize>(), columns.parse::<usize>())
{
return (columns, rows);
}
}
}
}
}
let width = env::var("COLUMNS")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(132);
let height = env::var("LINES")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(42);
(width, height)
}
fn load_text_from_test_sh() -> String {
let fallback =
"Knott Dynamics\nHumanoid robotics through tendons, leverage, and motion.".to_string();
let Ok(script) = fs::read_to_string("test.sh") else {
return fallback;
};
let mut sections = script.split(TEXT_MARKER);
sections.next();
sections
.next()
.map(|text| text.trim_matches(['\n', '\r']).to_string())
.filter(|text| !text.is_empty())
.unwrap_or(fallback)
}
fn snippets_for_effects(text: &str, width: usize, height: usize) -> Vec<String> {
EffectKind::all()
.iter()
.enumerate()
.map(|(index, _)| snippet(text, index, width, height))
.collect()
}
fn snippet(text: &str, index: usize, width: usize, height: usize) -> String {
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
return "Aisling".to_string();
}
let start = (index * 9) % words.len();
let mut lines = Vec::new();
let mut line = String::new();
let mut cursor = start;
while lines.len() < height.max(1) {
let word = words[cursor % words.len()];
let next_len = if line.is_empty() {
word.len()
} else {
line.len() + 1 + word.len()
};
if next_len > width.max(8) {
lines.push(line);
line = String::new();
if lines.len() >= height.max(1) {
break;
}
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
cursor += 1;
if cursor - start > width.max(8) * height.max(1) {
break;
}
}
if !line.is_empty() && lines.len() < height.max(1) {
lines.push(line);
}
lines.join("\n")
}
fn first_words(text: &str, width: usize) -> String {
let mut out = String::new();
for word in text.split_whitespace() {
if out.len() + word.len() + 1 > width {
break;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(word);
}
out
}
fn truncate(text: &str, max_width: usize) -> String {
if text.chars().count() <= max_width {
return text.to_string();
}
text.chars()
.take(max_width.saturating_sub(1))
.chain(std::iter::once('…'))
.collect()
}
fn effect_gradient(index: usize, theme: Theme) -> Gradient {
let a = theme.accent(index);
let b = theme.accent(index + 5).brighten(match theme {
Theme::Neon => 0.25,
Theme::Industrial => 0.10,
});
let c = theme.accent(index + 11);
Gradient::new(vec![a, b, c], 18)
}
fn gradient_direction(index: usize) -> GradientDirection {
match index % 4 {
0 => GradientDirection::Diagonal,
1 => GradientDirection::Horizontal,
2 => GradientDirection::Vertical,
_ => GradientDirection::Radial,
}
}
fn neon_color(index: usize) -> Color {
const COLORS: [Color; 12] = [
Color::rgb(0, 255, 210),
Color::rgb(255, 76, 210),
Color::rgb(130, 98, 255),
Color::rgb(0, 210, 255),
Color::rgb(255, 185, 60),
Color::rgb(130, 255, 75),
Color::rgb(255, 80, 88),
Color::rgb(70, 140, 255),
Color::rgb(220, 255, 90),
Color::rgb(255, 120, 45),
Color::rgb(190, 120, 255),
Color::rgb(80, 255, 170),
];
COLORS[index % COLORS.len()]
}
fn industrial_color(index: usize) -> Color {
const COLORS: [Color; 12] = [
Color::rgb(178, 126, 45),
Color::rgb(115, 126, 112),
Color::rgb(83, 92, 100),
Color::rgb(136, 72, 48),
Color::rgb(148, 132, 92),
Color::rgb(78, 98, 68),
Color::rgb(156, 86, 42),
Color::rgb(96, 111, 120),
Color::rgb(119, 103, 78),
Color::rgb(100, 78, 64),
Color::rgb(130, 118, 98),
Color::rgb(68, 84, 74),
];
COLORS[index % COLORS.len()]
}