//! Low-cost terminal wallpaper and procedural ambient animation. use std::{ fs, path::Path, time::{Duration, Instant}, }; use image::{ImageReader, RgbImage, imageops::FilterType}; use latchd_core::{ config::VisualConfig, error::{Error, ResultExt}, }; use ratatui::{ Frame, prelude::{Buffer, Color, Rect}, }; const MAX_IMAGE_SIDE: u32 = 480; const MAX_SOURCE_SIDE: u32 = 8_192; const MAX_SOURCE_PIXELS: u64 = 16_777_216; const MAX_ANIMATION_WIDTH: usize = 160; const MAX_ANIMATION_HEIGHT: usize = 64; const FRAME_INTERVAL: Duration = Duration::from_millis(120); /// Background state owned by the TUI thread. pub(crate) struct Background { wallpaper: Option, automata: Automata, enabled: bool, animations: bool, reduced_motion: bool, last_tick: Instant, } impl Background { /// Loads a bounded wallpaper image and prepares the procedural layer. pub(crate) fn load(config: &VisualConfig) -> Result { let wallpaper = config .wallpaper_path .as_deref() .map(|path| load_wallpaper(path, config.max_wallpaper_bytes)) .transpose()?; Ok(Self { wallpaper, automata: Automata::default(), enabled: config.background_enabled, animations: config.animations, reduced_motion: config.reduced_motion, last_tick: Instant::now(), }) } pub(crate) fn tick_if_due(&mut self, now: Instant) -> bool { if !self.enabled || !self.animations || self.reduced_motion || now.duration_since(self.last_tick) < FRAME_INTERVAL { return false; } self.automata.tick(); self.last_tick = now; true } pub(crate) fn toggle_enabled(&mut self) -> bool { self.enabled = !self.enabled; self.enabled } pub(crate) fn toggle_motion(&mut self) -> bool { self.reduced_motion = !self.reduced_motion; self.reduced_motion } pub(crate) fn draw(&mut self, frame: &mut Frame<'_>, area: Rect, ascii: bool, no_color: bool) { if !self.enabled || area.width < 80 || area.width == 0 || area.height == 0 { return; } self.automata .resize(area.width as usize, area.height as usize); let buffer = frame.buffer_mut(); draw_wallpaper(buffer, area, self.wallpaper.as_ref(), no_color); draw_automata(buffer, area, &self.automata, ascii, no_color); } } fn load_wallpaper(path: &Path, max_bytes: u64) -> Result { let metadata = fs::symlink_metadata(path).io_context(format!("inspect wallpaper {}", path.display()))?; if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { return Err(Error::Config(format!( "wallpaper must be a root-owned, non-group-writable regular file: {}", path.display() ))); } if metadata.len() > max_bytes { return Err(Error::Config(format!( "wallpaper exceeds visual.max_wallpaper_bytes: {}", path.display() ))); } let reader = ImageReader::open(path) .io_context(format!("open wallpaper {}", path.display()))? .with_guessed_format() .io_context(format!("inspect wallpaper format {}", path.display()))?; let (width, height) = reader.into_dimensions().map_err(|error| { Error::Config(format!( "inspect wallpaper dimensions {}: {error}", path.display() )) })?; if width == 0 || height == 0 || width > MAX_SOURCE_SIDE || height > MAX_SOURCE_SIDE || u64::from(width).saturating_mul(u64::from(height)) > MAX_SOURCE_PIXELS { return Err(Error::Config(format!( "wallpaper dimensions exceed {MAX_SOURCE_SIDE}x{MAX_SOURCE_SIDE} or {MAX_SOURCE_PIXELS} pixels" ))); } let image = ImageReader::open(path) .io_context(format!("open wallpaper {}", path.display()))? .with_guessed_format() .io_context(format!("inspect wallpaper format {}", path.display()))? .decode() .map_err(|error| Error::Config(format!("decode wallpaper {}: {error}", path.display())))?; if image.width() == 0 || image.height() == 0 { return Err(Error::Config( "wallpaper dimensions must be non-zero".into(), )); } let image = image.resize(MAX_IMAGE_SIDE, MAX_IMAGE_SIDE, FilterType::Triangle); Ok(image.to_rgb8()) } fn draw_wallpaper(buffer: &mut Buffer, area: Rect, wallpaper: Option<&RgbImage>, no_color: bool) { let Some(image) = wallpaper else { return }; if no_color { return; } let width = image.width().max(1); let height = image.height().max(1); for y in 0..area.height { for x in 0..area.width { let source_x = (u32::from(x) * width / u32::from(area.width)).min(width - 1); let source_y = (u32::from(y) * height / u32::from(area.height)).min(height - 1); let pixel = image.get_pixel(source_x, source_y).0; if let Some(cell) = buffer.cell_mut((area.x + x, area.y + y)) { cell.set_symbol(" "); cell.set_bg(Color::Rgb(pixel[0] / 3, pixel[1] / 3, pixel[2] / 3)); } } } } fn draw_automata( buffer: &mut Buffer, area: Rect, automata: &Automata, ascii: bool, no_color: bool, ) { for y in 0..area.height as usize { for x in 0..area.width as usize { if !automata.alive(x, y) { continue; } let x = x as u16; let y = y as u16; if let Some(cell) = buffer.cell_mut((area.x + x, area.y + y)) { cell.set_symbol(if ascii { "." } else { "ยท" }); if !no_color { cell.set_fg(Color::Rgb(60, 100, 120)); } } } } } #[derive(Default)] struct Automata { width: usize, height: usize, current: Vec, next: Vec, seed: u64, } impl Automata { fn resize(&mut self, width: usize, height: usize) { // Keep procedural state bounded even when a terminal reports an // unusually large window. Rendering remains clipped to this layer. let width = width.min(MAX_ANIMATION_WIDTH); let height = height.min(MAX_ANIMATION_HEIGHT); if self.width == width && self.height == height { return; } self.width = width; self.height = height; self.current = vec![0; width.saturating_mul(height)]; self.next = vec![0; width.saturating_mul(height)]; self.seed = self.seed.wrapping_add(0x9E37_79B9_7F4A_7C15); for index in 0..self.current.len() { self.current[index] = u8::from(self.random() % 11 == 0); } } fn tick(&mut self) { if self.width == 0 || self.height == 0 { return; } for y in 0..self.height { for x in 0..self.width { let neighbors = self.neighbors(x, y); let index = y * self.width + x; self.next[index] = u8::from(neighbors == 3 || (neighbors == 2 && self.current[index] == 1)); } } std::mem::swap(&mut self.current, &mut self.next); } fn neighbors(&self, x: usize, y: usize) -> u8 { let mut count = 0; for dy in [self.height.wrapping_sub(1), 0, 1] { for dx in [self.width.wrapping_sub(1), 0, 1] { let nx = (x + dx) % self.width; let ny = (y + dy) % self.height; if nx == x && ny == y { continue; } count += self.current[ny * self.width + nx]; } } count } fn alive(&self, x: usize, y: usize) -> bool { self.current .get(y.saturating_mul(self.width).saturating_add(x)) .copied() == Some(1) } fn random(&mut self) -> u64 { let mut value = self.seed; value ^= value << 13; value ^= value >> 7; value ^= value << 17; self.seed = value; value } } #[cfg(unix)] use std::os::unix::fs::MetadataExt; #[cfg(test)] mod tests { use super::*; #[test] fn automata_is_bounded_and_progresses() { let mut automata = Automata::default(); automata.resize(8, 4); assert_eq!(automata.current.len(), 32); automata.tick(); assert_eq!(automata.current.len(), 32); } #[test] fn automata_caps_untrusted_terminal_dimensions() { let mut automata = Automata::default(); automata.resize(usize::MAX, usize::MAX); assert_eq!(automata.width, MAX_ANIMATION_WIDTH); assert_eq!(automata.height, MAX_ANIMATION_HEIGHT); assert_eq!( automata.current.len(), MAX_ANIMATION_WIDTH * MAX_ANIMATION_HEIGHT ); } }