Files
latchd/crates/latchd-tui/src/background.rs

286 lines
8.9 KiB
Rust

//! 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<RgbImage>,
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<Self, Error> {
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<RgbImage, Error> {
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<u8>,
next: Vec<u8>,
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
);
}
}