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 theme = args.theme;
let panes = build_panes(&text, &layout, args.duration_frames, theme);
let pages = build_pages(&panes, &layout);
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 = (tick / page_ticks) % pages.len().max(1);
let screen = compose_screen(
&panes,
&pages,
&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::Curated,
};
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,
"--rainbow" | "--neon" => parsed.theme = Theme::Rainbow,
"--curated" | "--bold" | "--default" => parsed.theme = Theme::Curated,
"--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 {
Curated,
Rainbow,
Industrial,
}
impl Theme {
fn parse(value: &str) -> Option<Self> {
match value.to_ascii_lowercase().as_str() {
"curated" | "bold" | "default" => Some(Self::Curated),
"rainbow" | "neon" => Some(Self::Rainbow),
"industrial" | "steel" | "dark" | "manly" => Some(Self::Industrial),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Self::Curated => "curated",
Self::Rainbow => "rainbow",
Self::Industrial => "industrial",
}
}
fn screen_bg(self) -> Color {
match self {
Self::Curated => Color::rgb(5, 8, 12),
Self::Rainbow => Color::rgb(4, 6, 13),
Self::Industrial => Color::rgb(10, 11, 10),
}
}
fn header_bg(self) -> Color {
match self {
Self::Curated => Color::rgb(9, 14, 20),
Self::Rainbow => Color::rgb(8, 10, 24),
Self::Industrial => Color::rgb(18, 17, 14),
}
}
fn pane_bg(self) -> Color {
match self {
Self::Curated => Color::rgb(11, 16, 23),
Self::Rainbow => Color::rgb(8, 11, 22),
Self::Industrial => Color::rgb(16, 17, 16),
}
}
fn title_color(self) -> Color {
match self {
Self::Curated => Color::rgb(255, 176, 74),
Self::Rainbow => Color::rgb(0, 255, 210),
Self::Industrial => Color::rgb(216, 166, 72),
}
}
fn subtitle_color(self) -> Color {
match self {
Self::Curated => Color::rgb(112, 176, 255),
Self::Rainbow => Color::rgb(255, 120, 220),
Self::Industrial => Color::rgb(150, 154, 145),
}
}
fn source_color(self) -> Color {
match self {
Self::Curated => Color::rgb(188, 207, 224),
Self::Rainbow => Color::rgb(148, 180, 255),
Self::Industrial => Color::rgb(124, 134, 116),
}
}
fn footer_line(self) -> Color {
match self {
Self::Curated => Color::rgb(62, 85, 105),
Self::Rainbow => Color::rgb(70, 80, 130),
Self::Industrial => Color::rgb(74, 69, 58),
}
}
fn footer_text(self) -> Color {
match self {
Self::Curated => Color::rgb(176, 187, 196),
Self::Rainbow => Color::rgb(160, 170, 190),
Self::Industrial => Color::rgb(158, 146, 122),
}
}
fn base_cell(self) -> ScreenCell {
ScreenCell {
ch: ' ',
fg: Some(match self {
Self::Curated => Color::rgb(40, 52, 62),
Self::Rainbow => Color::rgb(32, 36, 48),
Self::Industrial => Color::rgb(42, 42, 38),
}),
bg: Some(self.screen_bg()),
bold: false,
}
}
fn accent(self, kind: EffectKind, index: usize) -> Color {
match self {
Self::Curated => curated_color(kind, index),
Self::Rainbow => rainbow_color(index),
Self::Industrial => industrial_color(index),
}
}
fn header_accent(self, index: usize) -> Color {
match self {
Self::Curated => curated_header_color(index),
Self::Rainbow => rainbow_color(index),
Self::Industrial => industrial_color(index),
}
}
fn tune_cell_color(self, color: Color, accent: Color) -> Color {
match self {
Self::Curated => color.dim(0.72).blend(accent, 0.38).brighten(0.12),
Self::Rainbow => 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,
header_height: usize,
footer_height: 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);
Self {
width,
height,
columns,
rows,
pane_width,
pane_height,
header_height,
footer_height,
}
}
}
#[derive(Clone, Copy, Debug)]
struct PaneShape {
columns: usize,
rows: usize,
stagger: bool,
}
#[derive(Clone, Copy, Debug)]
struct PanePlacement {
pane_index: usize,
column: usize,
row: usize,
x: usize,
y: usize,
width: usize,
height: usize,
}
#[derive(Clone)]
struct PaneRuntime {
index: usize,
kind: EffectKind,
frames: Vec<Frame>,
offset: usize,
speed_numerator: usize,
accent: Color,
shape: PaneShape,
}
#[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(
text: &str,
layout: &Layout,
base_duration: usize,
theme: Theme,
) -> Vec<PaneRuntime> {
EffectKind::all()
.iter()
.enumerate()
.map(|(index, kind)| {
let shape = pane_shape(*kind, index, layout);
let (pane_width, pane_height) = pane_size(layout, shape);
let inner_width = pane_width.saturating_sub(2).max(8);
let inner_height = pane_height.saturating_sub(2).max(3);
let snippet = snippet(text, index, inner_width, inner_height);
let accent = theme.accent(*kind, 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(*kind, index, theme),
gradient_direction(index),
);
let frames = Effect::with_config(*kind, snippet, config).frames();
PaneRuntime {
index,
kind: *kind,
frames,
offset: index * 13,
speed_numerator: 1 + (index % 4),
accent,
shape,
}
})
.collect()
}
fn build_pages(panes: &[PaneRuntime], layout: &Layout) -> Vec<Vec<PanePlacement>> {
let mut pages = Vec::new();
let mut occupied = vec![false; layout.columns * layout.rows];
let mut current = Vec::new();
for pane in panes {
if let Some(placement) = place_pane(&occupied, layout, pane) {
mark_occupied(&mut occupied, layout, pane.shape, placement);
current.push(placement);
continue;
}
if !current.is_empty() {
pages.push(current);
}
occupied = vec![false; layout.columns * layout.rows];
current = Vec::new();
let placement = place_pane(&occupied, layout, pane)
.unwrap_or_else(|| fallback_placement(layout, pane.index));
mark_occupied(&mut occupied, layout, pane.shape, placement);
current.push(placement);
}
if !current.is_empty() {
pages.push(current);
}
if pages.is_empty() {
pages.push(Vec::new());
}
pages
}
fn pane_shape(kind: EffectKind, index: usize, layout: &Layout) -> PaneShape {
let (columns, rows) = match kind {
EffectKind::Matrix => (3, 2),
EffectKind::Rain | EffectKind::Thunderstorm | EffectKind::Pour => (1, 2),
EffectKind::VhsTape
| EffectKind::SynthGrid
| EffectKind::LaserEtch
| EffectKind::Beams
| EffectKind::ColorShift
| EffectKind::Overflow
| EffectKind::Print
| EffectKind::Spotlights
| EffectKind::Waves => (2, 1),
EffectKind::Blackhole | EffectKind::Rings | EffectKind::Fireworks => (2, 2),
_ => (1, 1),
};
PaneShape {
columns: columns.min(layout.columns).max(1),
rows: rows.min(layout.rows).max(1),
stagger: index % 2 == 1,
}
}
fn pane_size(layout: &Layout, shape: PaneShape) -> (usize, usize) {
let width = (layout.pane_width * shape.columns)
.min(layout.width)
.max(12);
let height = (layout.pane_height * shape.rows)
.min(
layout
.height
.saturating_sub(layout.header_height + layout.footer_height),
)
.max(6);
(width, height)
}
fn place_pane(occupied: &[bool], layout: &Layout, pane: &PaneRuntime) -> Option<PanePlacement> {
let shape = pane.shape;
if shape.columns > layout.columns || shape.rows > layout.rows {
return None;
}
for row in 0..=layout.rows.saturating_sub(shape.rows) {
for column in 0..=layout.columns.saturating_sub(shape.columns) {
if is_free(occupied, layout, column, row, shape) {
return Some(pixel_placement(layout, pane.index, shape, column, row));
}
}
}
None
}
fn is_free(
occupied: &[bool],
layout: &Layout,
column: usize,
row: usize,
shape: PaneShape,
) -> bool {
for dy in 0..shape.rows {
for dx in 0..shape.columns {
let index = (row + dy) * layout.columns + column + dx;
if occupied.get(index).copied().unwrap_or(true) {
return false;
}
}
}
true
}
fn mark_occupied(
occupied: &mut [bool],
layout: &Layout,
shape: PaneShape,
placement: PanePlacement,
) {
for dy in 0..shape.rows {
for dx in 0..shape.columns {
let index = (placement.row + dy) * layout.columns + placement.column + dx;
if let Some(cell) = occupied.get_mut(index) {
*cell = true;
}
}
}
}
fn pixel_placement(
layout: &Layout,
pane_index: usize,
shape: PaneShape,
column: usize,
row: usize,
) -> PanePlacement {
let (base_width, base_height) = pane_size(layout, shape);
let offset_x = if shape.stagger && column + shape.columns < layout.columns {
(layout.pane_width / 5).clamp(1, 5)
} else {
0
};
let offset_y = if shape.stagger && row + shape.rows < layout.rows {
1
} else {
0
};
let x = column * layout.pane_width + offset_x;
let y = layout.header_height + row * layout.pane_height + offset_y;
let max_height = layout.height.saturating_sub(layout.footer_height + y);
let adjusted_width = base_width.saturating_sub(offset_x).max(4);
let adjusted_height = base_height.saturating_sub(offset_y).max(4);
PanePlacement {
pane_index,
column,
row,
x,
y,
width: adjusted_width.min(layout.width.saturating_sub(x)).max(4),
height: adjusted_height.min(max_height).max(4),
}
}
fn fallback_placement(layout: &Layout, pane_index: usize) -> PanePlacement {
let shape = PaneShape {
columns: 1,
rows: 1,
stagger: false,
};
pixel_placement(layout, pane_index, shape, 0, 0)
}
fn compose_screen(
panes: &[PaneRuntime],
pages: &[Vec<PanePlacement>],
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,
pages.len(),
pages.get(page).map_or(0, Vec::len),
text,
elapsed,
theme,
);
if let Some(placements) = pages.get(page) {
for placement in placements {
if let Some(pane) = panes.get(placement.pane_index) {
paint_pane(&mut screen, layout, *placement, pane, tick, theme);
}
}
}
paint_footer(&mut screen, layout, theme);
screen
}
fn paint_header(
screen: &mut [ScreenCell],
layout: &Layout,
effect_count: usize,
page: usize,
page_count: usize,
pane_count: usize,
text: &str,
elapsed: Duration,
theme: Theme,
) {
let title = " AISLING // ALL EFFECTS MULTI-PANE SHOWCASE ";
let subtitle = format!(
" {} effects | {} theme | masonry {}x{} | {} panes here | page {}/{} | {:.1}s ",
effect_count,
theme.name(),
layout.columns,
layout.rows,
pane_count,
page + 1,
page_count,
elapsed.as_secs_f32()
);
let source = first_words(text, layout.width.saturating_sub(4));
for x in 0..layout.width {
let color = theme.header_accent(x / 10);
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 | default is curated | --rainbow for full color | --industrial for steel/amber | matrix gets a long pane ",
Some(theme.footer_text()),
Some(theme.screen_bg()),
false,
);
}
fn paint_pane(
screen: &mut [ScreenCell],
layout: &Layout,
placement: PanePlacement,
pane: &PaneRuntime,
tick: usize,
theme: Theme,
) {
let x = placement.x;
let y = placement.y;
let w = placement.width.min(layout.width.saturating_sub(x));
let h = placement
.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..pane.frames[frame_index].height().min(h.saturating_sub(2)) {
for fx in 0..pane.frames[frame_index].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 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(kind: EffectKind, index: usize, theme: Theme) -> Gradient {
let a = theme.accent(kind, index);
let b = theme.accent(kind, index + 5).brighten(match theme {
Theme::Curated => 0.18,
Theme::Rainbow => 0.25,
Theme::Industrial => 0.10,
});
let c = theme.accent(kind, 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 curated_color(kind: EffectKind, index: usize) -> Color {
match kind {
EffectKind::Matrix | EffectKind::BinaryPath | EffectKind::Decrypt => {
Color::rgb(55, 220, 110)
}
EffectKind::Burn | EffectKind::Fireworks | EffectKind::LaserEtch => {
Color::rgb(255, 126, 48)
}
EffectKind::Rain | EffectKind::Thunderstorm | EffectKind::Bubbles => {
Color::rgb(70, 165, 255)
}
EffectKind::Blackhole | EffectKind::Rings | EffectKind::Spotlights => {
Color::rgb(168, 118, 255)
}
EffectKind::Smoke | EffectKind::Crumble | EffectKind::VhsTape | EffectKind::Unstable => {
Color::rgb(185, 196, 204)
}
EffectKind::SynthGrid | EffectKind::ColorShift | EffectKind::Waves => {
Color::rgb(0, 212, 200)
}
EffectKind::Print | EffectKind::Overflow | EffectKind::Sweep | EffectKind::Wipe => {
Color::rgb(255, 184, 82)
}
_ => curated_header_color(index),
}
}
fn curated_header_color(index: usize) -> Color {
const COLORS: [Color; 8] = [
Color::rgb(255, 170, 60),
Color::rgb(74, 156, 255),
Color::rgb(55, 210, 118),
Color::rgb(214, 95, 66),
Color::rgb(175, 120, 255),
Color::rgb(0, 196, 190),
Color::rgb(224, 230, 236),
Color::rgb(235, 136, 65),
];
COLORS[index % COLORS.len()]
}
fn rainbow_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()]
}