use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use crate::animation::AnimationState;
use crate::app::{App, JumpMode, Screen, TopPage, ViewMode};
use crate::demo;
use crate::ui;
use crate::ui::svg_export::{AnimFrame, SvgOpts, buffer_to_svg, frames_to_animated_svg};
struct Scene {
name: &'static str,
width: u16,
height: u16,
setup: fn(&mut App),
crop: Option<(u16, u16, u16, u16)>,
}
const SCENES: &[Scene] = &[
Scene {
name: "hosts",
width: 120,
height: 34,
setup: setup_hosts,
crop: None,
},
Scene {
name: "search",
width: 100,
height: 30,
setup: setup_search,
crop: None,
},
Scene {
name: "jump",
width: 100,
height: 30,
setup: setup_jump,
crop: None,
},
Scene {
name: "providers",
width: 100,
height: 30,
setup: setup_providers,
crop: None,
},
Scene {
name: "tunnels",
width: 120,
height: 36,
setup: setup_tunnels,
crop: None,
},
Scene {
name: "containers",
width: 130,
height: 40,
setup: setup_containers,
crop: None,
},
Scene {
name: "snippets",
width: 160,
height: 40,
setup: setup_snippets,
crop: None,
},
Scene {
name: "keys",
width: 130,
height: 40,
setup: setup_keys,
crop: None,
},
Scene {
name: "og-card",
width: 124,
height: 30,
setup: setup_hosts,
crop: None,
},
Scene {
name: "zoom-host-detail",
width: 120,
height: 34,
setup: setup_hosts,
crop: Some((79, 3, 41, 19)),
},
Scene {
name: "zoom-jump",
width: 100,
height: 30,
setup: setup_jump,
crop: Some((13, 7, 74, 16)),
},
Scene {
name: "zoom-providers",
width: 100,
height: 30,
setup: setup_providers,
crop: Some((15, 5, 68, 19)),
},
Scene {
name: "zoom-tunnel-live",
width: 90,
height: 32,
setup: setup_tunnels,
crop: Some((0, 17, 90, 12)),
},
Scene {
name: "zoom-container-fleet",
width: 90,
height: 40,
setup: setup_containers,
crop: Some((0, 6, 90, 14)),
},
Scene {
name: "zoom-snippet-impact",
width: 160,
height: 40,
setup: setup_snippets,
crop: Some((87, 10, 73, 18)),
},
Scene {
name: "zoom-key-randomart",
width: 100,
height: 40,
setup: setup_keys,
crop: Some((28, 9, 72, 17)),
},
Scene {
name: "zoom-vault-ttl",
width: 90,
height: 40,
setup: setup_keys,
crop: Some((0, 3, 90, 6)),
},
];
fn crop_buffer(src: &Buffer, (x, y, w, h): (u16, u16, u16, u16)) -> Buffer {
let mut out = Buffer::empty(ratatui::layout::Rect::new(0, 0, w, h));
for dy in 0..h {
for dx in 0..w {
out[(dx, dy)] = src[(x + dx, y + dy)].clone();
}
}
out
}
fn setup_hosts(app: &mut App) {
app.hosts_state.set_view_mode(ViewMode::Detailed);
}
fn setup_search(app: &mut App) {
app.start_search_with("aws");
}
fn setup_jump(app: &mut App) {
app.open_jump(JumpMode::Hosts);
}
fn setup_providers(app: &mut App) {
app.screen = Screen::Providers;
}
fn setup_tunnels(app: &mut App) {
app.top_page = TopPage::Tunnels;
demo::seed_tunnel_live_snapshots(app);
app.ui.tunnels_overview_state_mut().select(Some(0));
}
fn setup_containers(app: &mut App) {
app.top_page = TopPage::Containers;
let first_container = crate::ui::containers_overview::visible_items(app)
.iter()
.position(|i| i.as_container().is_some())
.unwrap_or(0);
app.ui
.containers_overview_state_mut()
.select(Some(first_container));
}
fn setup_snippets(app: &mut App) {
app.top_page = TopPage::Snippets;
let deploy = app
.snippets
.store()
.snippets
.iter()
.position(|s| s.name == "deploy")
.unwrap_or(0);
app.snippets.list_state_mut().select(Some(deploy));
}
fn setup_keys(app: &mut App) {
app.top_page = TopPage::Keys;
app.keys.list_state_mut().select(Some(0));
}
pub fn generate(out_dir: &Path, font_dir: Option<&Path>) -> io::Result<Vec<PathBuf>> {
fs::create_dir_all(out_dir)?;
ui::theme::set_color_mode(2);
ui::theme::set_theme(ui::theme::ThemeDef::purple());
let font_face_css = match font_dir {
Some(dir) => build_font_css(dir),
None => String::new(),
};
let mut written = Vec::with_capacity(SCENES.len());
for scene in SCENES {
let mut app = demo::build_demo_app();
(scene.setup)(&mut app);
let backend = TestBackend::new(scene.width, scene.height);
let mut terminal = Terminal::new(backend).map_err(|e| io::Error::other(e.to_string()))?;
let mut anim = AnimationState::default();
terminal
.draw(|f| ui::render(f, &mut app, &mut anim))
.map_err(|e| io::Error::other(e.to_string()))?;
let mut buf = terminal.backend().buffer().clone();
if let Some(region) = scene.crop {
buf = crop_buffer(&buf, region);
}
let rounded = scene.crop.is_none();
let opts = SvgOpts {
font_family: "'Berkeley Mono','JetBrains Mono',monospace".to_string(),
font_face_css: font_face_css.clone(),
rounded,
pad: if rounded { 14 } else { 0 },
..SvgOpts::default()
};
let svg = buffer_to_svg(&buf, &opts);
let path = out_dir.join(format!("{}.svg", scene.name));
crate::fs_util::atomic_write(&path, svg.as_bytes())?;
log::debug!(
"[purple] gen-assets wrote {} ({} bytes)",
path.display(),
svg.len()
);
written.push(path);
}
Ok(written)
}
const HERO_W: u16 = 120;
const HERO_H: u16 = 34;
fn hero_buffer(app: &mut App) -> io::Result<Buffer> {
let backend = TestBackend::new(HERO_W, HERO_H);
let mut terminal = Terminal::new(backend).map_err(|e| io::Error::other(e.to_string()))?;
let mut anim = AnimationState::default();
terminal
.draw(|f| ui::render(f, app, &mut anim))
.map_err(|e| io::Error::other(e.to_string()))?;
Ok(terminal.backend().buffer().clone())
}
fn hero_frames() -> io::Result<Vec<AnimFrame>> {
let mut frames = Vec::with_capacity(9);
let mut app = demo::build_demo_app();
setup_hosts(&mut app);
frames.push(AnimFrame {
buf: hero_buffer(&mut app)?,
dur_ms: 1600,
keyframe: true,
});
for (query, dur_ms) in [("p", 200), ("pr", 200), ("pro", 200), ("prod", 2600)] {
app.start_search_with(query);
frames.push(AnimFrame {
buf: hero_buffer(&mut app)?,
dur_ms,
keyframe: true,
});
}
let pages: [fn(&mut App); 4] = [setup_tunnels, setup_containers, setup_snippets, setup_keys];
for setup in pages {
let mut app = demo::build_demo_app();
setup(&mut app);
frames.push(AnimFrame {
buf: hero_buffer(&mut app)?,
dur_ms: 3600,
keyframe: true,
});
}
Ok(frames)
}
pub fn generate_hero(out_path: &Path, font_dir: Option<&Path>) -> io::Result<()> {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
ui::theme::set_color_mode(2);
ui::theme::set_theme(ui::theme::ThemeDef::purple());
let font_face_css = font_dir.map(build_font_css).unwrap_or_default();
let opts = SvgOpts {
font_family: "'Berkeley Mono','JetBrains Mono',monospace".to_string(),
font_face_css,
rounded: true,
pad: 14,
..SvgOpts::default()
};
let frames = hero_frames()?;
let svg = frames_to_animated_svg(&frames, 0, &opts);
crate::fs_util::atomic_write(out_path, svg.as_bytes())?;
log::debug!(
"[purple] gen-assets wrote {} ({} bytes, {} frames)",
out_path.display(),
svg.len(),
frames.len()
);
Ok(())
}
fn build_font_css(font_dir: &Path) -> String {
let mut css = String::new();
if let Ok(bytes) = fs::read(font_dir.join("ui-mono-regular.woff2")) {
css.push_str(&font_face(400, &bytes));
}
if let Ok(bytes) = fs::read(font_dir.join("ui-mono-bold.woff2")) {
css.push_str(&font_face(700, &bytes));
}
css
}
fn font_face(weight: u32, bytes: &[u8]) -> String {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
format!(
"@font-face{{font-family:'JetBrains Mono';font-style:normal;font-weight:{weight};src:url(data:font/woff2;base64,{b64}) format('woff2');}}"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crop_buffer_copies_exactly_the_requested_region() {
let mut src = Buffer::empty(ratatui::layout::Rect::new(0, 0, 10, 5));
src[(2, 1)].set_symbol("A");
src[(5, 3)].set_symbol("B");
src[(0, 0)].set_symbol("X"); let out = crop_buffer(&src, (2, 1, 4, 3));
assert_eq!(out.area.width, 4);
assert_eq!(out.area.height, 3);
assert_eq!(out[(0, 0)].symbol(), "A", "top-left maps to crop origin");
assert_eq!(out[(3, 2)].symbol(), "B", "bottom-right inside crop");
let all: String = (0..4)
.flat_map(|x| (0..3).map(move |y| (x, y)))
.map(|(x, y)| out[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect();
assert!(!all.contains('X'), "outside cells are not copied");
}
#[test]
fn generate_hero_writes_one_looping_animated_svg() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let out = tmp.path().join("hero.svg");
let font_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/fonts");
generate_hero(&out, Some(&font_dir)).expect("generate_hero");
let body = fs::read_to_string(&out).expect("read hero svg");
assert!(body.starts_with("<svg"), "svg root");
assert!(body.contains("</svg>"), "closed svg");
assert!(body.contains("class=\"panim\""), "animated root class");
assert!(body.contains("@keyframes"), "css timeline present");
assert!(
body.contains("prefers-reduced-motion"),
"reduced motion fallback present"
);
assert!(body.contains("pf-fb"), "static fallback scene marked");
assert!(body.contains("Berkeley Mono"), "primary face");
assert!(body.contains("@font-face"), "embedded fallback font");
assert_eq!(
body.matches("<g class=\"pf pf").count(),
9,
"expected 9 frame groups"
);
assert!(body.contains(">search:</text>"), "search UI appears");
for query in ["p", "pr", "pro", "prod"] {
assert!(
body.contains(&format!(">{query}</text>")),
"query '{query}' renders in a search keyframe"
);
}
let out2 = tmp.path().join("hero2.svg");
generate_hero(&out2, Some(&font_dir)).expect("generate_hero again");
let body2 = fs::read_to_string(&out2).expect("read second hero svg");
assert_eq!(body, body2, "hero generation is deterministic");
crate::demo_flag::disable();
}
#[test]
fn hero_frames_are_all_keyframes() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let frames = hero_frames().expect("hero_frames");
assert!(
frames.iter().all(|f| f.keyframe),
"all hero frames must be keyframes so no delta erase rects are emitted"
);
crate::demo_flag::disable();
}
#[test]
fn generate_writes_one_svg_per_scene() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let font_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/fonts");
let written = generate(tmp.path(), Some(&font_dir)).expect("generate");
assert_eq!(written.len(), SCENES.len(), "one file per scene");
let mut saw_brand_purple = false;
for path in &written {
let body = fs::read_to_string(path).expect("read svg");
saw_brand_purple |= body.contains("#9333ea");
assert!(body.starts_with("<svg"), "svg root in {}", path.display());
assert!(body.contains("</svg>"), "closed svg in {}", path.display());
assert!(
body.contains("<rect"),
"has cell rects in {}",
path.display()
);
assert!(
body.contains("@font-face"),
"embeds fallback font in {}",
path.display()
);
assert!(
body.contains("Berkeley Mono"),
"uses Berkeley Mono as the primary face in {}",
path.display()
);
}
assert!(
saw_brand_purple,
"truecolor mode emits the brand purple #9333ea in at least one scene"
);
crate::demo_flag::disable();
}
#[test]
fn generate_with_no_font_dir_omits_embedded_font() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let written = generate(tmp.path(), None).expect("generate");
assert!(!written.is_empty(), "scenes written without a font dir");
let body = fs::read_to_string(&written[0]).expect("read svg");
assert!(body.starts_with("<svg"), "svg root");
assert!(body.contains("<rect"), "has cell rects");
assert!(
!body.contains("@font-face"),
"no embedded font without a font dir"
);
crate::demo_flag::disable();
}
#[test]
fn build_font_css_skips_absent_weights() {
let empty = tempfile::tempdir().expect("tempdir");
assert!(build_font_css(empty.path()).is_empty(), "no files, no css");
let partial = tempfile::tempdir().expect("tempdir");
let regular =
Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/fonts/ui-mono-regular.woff2");
fs::copy(®ular, partial.path().join("ui-mono-regular.woff2")).expect("copy regular");
let css = build_font_css(partial.path());
assert!(css.contains("font-weight:400"), "regular weight embedded");
assert!(!css.contains("font-weight:700"), "bold skipped when absent");
}
}