286 lines
8.9 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|