feat: ship Rust login manager and GitLab release pipeline
Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI.
This commit is contained in:
267
crates/latchd-tui/src/background.rs
Normal file
267
crates/latchd-tui/src/background.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
//! 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 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) {
|
||||
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] {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
let nx = (x + dx) % self.width;
|
||||
let ny = (y + dy) % self.height;
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user