init v.1.0.0
All checks were successful
CI / Check / Test (push) Successful in 3m40s

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-08-06 20:11:22 +02:00
commit 15fdace324
38 changed files with 2883 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
[package]
name = "assets"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
application = { workspace = true }
[dev-dependencies]
application = { workspace = true }

View File

@@ -0,0 +1,113 @@
use std::{
env, fmt, fs, io,
path::{Path, PathBuf},
};
use application::{ApplicationError, LogoSourcePort};
const LOGO_ENV: &str = "DVD_LOGO";
const CONFIG_DIR: &str = "dvd-thing";
const LOGO_FILE: &str = "logo.png";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Xdg,
MacOs,
Windows,
}
impl Platform {
pub const CURRENT: Platform = if cfg!(windows) {
Platform::Windows
} else if cfg!(target_os = "macos") {
Platform::MacOs
} else {
Platform::Xdg
};
}
pub fn config_path(platform: Platform, env: impl Fn(&str) -> Option<PathBuf>) -> Option<PathBuf> {
let lookup = |key: &str| set(env(key));
let base = match platform {
Platform::Xdg => absolute(lookup("XDG_CONFIG_HOME"))
.or_else(|| lookup("HOME").map(|home| home.join(".config")))?,
Platform::MacOs => absolute(lookup("XDG_CONFIG_HOME")).or_else(|| {
lookup("HOME").map(|home| home.join("Library").join("Application Support"))
})?,
Platform::Windows => lookup("APPDATA")
.or_else(|| lookup("USERPROFILE").map(|home| home.join("AppData").join("Roaming")))?,
};
Some(base.join(CONFIG_DIR).join(LOGO_FILE))
}
fn set(path: Option<PathBuf>) -> Option<PathBuf> {
path.filter(|path| !path.as_os_str().is_empty())
}
fn absolute(path: Option<PathBuf>) -> Option<PathBuf> {
path.filter(|path| path.is_absolute())
}
pub struct FileLogoSource {
bytes: Option<Vec<u8>>,
}
impl FileLogoSource {
pub fn from_env() -> Result<Self, ApplicationError> {
FileLogoSource::from_environment(Platform::CURRENT, &|key| {
env::var_os(key).map(PathBuf::from)
})
}
pub fn from_environment(
platform: Platform,
env: &impl Fn(&str) -> Option<PathBuf>,
) -> Result<Self, ApplicationError> {
FileLogoSource::new(set(env(LOGO_ENV)), config_path(platform, env))
}
pub fn new(
explicit: Option<PathBuf>,
configured: Option<PathBuf>,
) -> Result<Self, ApplicationError> {
let bytes = match (&explicit, &configured) {
(Some(path), _) => Some(
read(path)
.map_err(|e| failed(path, &format!("{e} (from ${LOGO_ENV})")))?
.ok_or_else(|| failed(path, &format!("no such file (from ${LOGO_ENV})")))?,
),
(None, Some(path)) => read(path).map_err(|e| failed(path, &e.to_string()))?,
(None, None) => None,
};
Ok(FileLogoSource { bytes })
}
}
impl fmt::Debug for FileLogoSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FileLogoSource")
.field("bytes", &self.bytes.as_ref().map(Vec::len))
.finish()
}
}
impl LogoSourcePort for FileLogoSource {
fn load(&self) -> Result<Option<&[u8]>, ApplicationError> {
Ok(self.bytes.as_deref())
}
}
fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
fn failed(path: &Path, reason: &str) -> ApplicationError {
ApplicationError::logo_source(format_args!("{}: {reason}", path.display()))
}

View File

@@ -0,0 +1,41 @@
#![allow(dead_code)]
use std::{
fs,
path::{Path, PathBuf},
};
pub const LOGO_BYTES: &[u8] = b"pretend png";
pub fn path(value: &str) -> Option<PathBuf> {
Some(PathBuf::from(value))
}
pub fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<PathBuf> + use<> {
let pairs: Vec<(String, PathBuf)> = pairs
.iter()
.map(|(key, value)| ((*key).to_owned(), PathBuf::from(value)))
.collect();
move |key| {
pairs
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value.clone())
}
}
pub fn scratch(name: &str) -> PathBuf {
let directory = std::env::temp_dir().join(format!("dvd-thing-test-{name}"));
let _ = fs::remove_dir_all(&directory);
fs::create_dir_all(&directory).expect("temp directory must be creatable");
directory
}
pub fn write_logo(directory: &Path) -> PathBuf {
let file = directory.join("logo.png");
fs::write(&file, LOGO_BYTES).expect("temp file must be writable");
file
}

View File

@@ -0,0 +1,117 @@
mod common;
use assets::{Platform, config_path};
use common::{env_of, path};
#[test]
fn xdg_prefers_config_home() {
let env = env_of(&[("XDG_CONFIG_HOME", "/xdg"), ("HOME", "/home/user")]);
assert_eq!(
config_path(Platform::Xdg, env),
path("/xdg/dvd-thing/logo.png")
);
}
#[test]
fn xdg_falls_back_to_home_dot_config() {
let env = env_of(&[("HOME", "/home/user")]);
assert_eq!(
config_path(Platform::Xdg, env),
path("/home/user/.config/dvd-thing/logo.png")
);
}
#[test]
fn xdg_ignores_a_relative_config_home() {
let env = env_of(&[("XDG_CONFIG_HOME", "relative/dir"), ("HOME", "/home/user")]);
assert_eq!(
config_path(Platform::Xdg, env),
path("/home/user/.config/dvd-thing/logo.png")
);
}
#[test]
fn macos_uses_application_support() {
let env = env_of(&[("HOME", "/Users/gabriel")]);
assert_eq!(
config_path(Platform::MacOs, env),
path("/Users/gabriel/Library/Application Support/dvd-thing/logo.png")
);
}
#[test]
fn macos_still_honours_an_explicit_xdg_config_home() {
let env = env_of(&[("XDG_CONFIG_HOME", "/Users/gabriel/.config")]);
assert_eq!(
config_path(Platform::MacOs, env),
path("/Users/gabriel/.config/dvd-thing/logo.png")
);
}
#[test]
fn windows_prefers_appdata() {
let env = env_of(&[
("APPDATA", r"C:\Users\gabriel\AppData\Roaming"),
("USERPROFILE", r"C:\Users\gabriel"),
]);
assert_eq!(
config_path(Platform::Windows, env),
path(r"C:\Users\gabriel\AppData\Roaming/dvd-thing/logo.png")
);
}
#[test]
fn windows_falls_back_to_user_profile() {
let env = env_of(&[("USERPROFILE", r"C:\Users\gabriel")]);
assert_eq!(
config_path(Platform::Windows, env),
path(r"C:\Users\gabriel/AppData/Roaming/dvd-thing/logo.png")
);
}
#[test]
fn windows_ignores_unix_variables() {
let env = env_of(&[("HOME", "/home/user"), ("XDG_CONFIG_HOME", "/xdg")]);
assert_eq!(config_path(Platform::Windows, env), None);
}
#[test]
fn an_empty_environment_yields_no_path() {
for platform in [Platform::Xdg, Platform::MacOs, Platform::Windows] {
assert_eq!(config_path(platform, env_of(&[])), None);
}
}
#[test]
fn an_empty_variable_counts_as_unset() {
let env = env_of(&[("XDG_CONFIG_HOME", ""), ("HOME", "/home/user")]);
assert_eq!(
config_path(Platform::Xdg, env),
path("/home/user/.config/dvd-thing/logo.png")
);
}
#[test]
fn an_empty_home_does_not_produce_a_relative_path() {
assert_eq!(config_path(Platform::Xdg, env_of(&[("HOME", "")])), None);
assert_eq!(config_path(Platform::MacOs, env_of(&[("HOME", "")])), None);
}
#[test]
fn an_empty_appdata_falls_back_to_user_profile() {
let env = env_of(&[("APPDATA", ""), ("USERPROFILE", r"C:\Users\gabriel")]);
assert_eq!(
config_path(Platform::Windows, env),
path(r"C:\Users\gabriel/AppData/Roaming/dvd-thing/logo.png")
);
}

View File

@@ -0,0 +1,86 @@
mod common;
use application::LogoSourcePort;
use assets::{FileLogoSource, Platform};
use common::{LOGO_BYTES, env_of, scratch, write_logo};
#[test]
fn reads_an_explicit_path() {
let logo = write_logo(&scratch("explicit"));
let source = FileLogoSource::new(Some(logo), None).unwrap();
assert_eq!(source.load().unwrap(), Some(LOGO_BYTES));
}
#[test]
fn a_missing_explicit_path_is_an_error() {
let missing = scratch("explicit-missing").join("absent.png");
let error = FileLogoSource::new(Some(missing), None).unwrap_err();
assert!(error.to_string().contains("DVD_LOGO"));
}
#[test]
fn an_explicit_path_wins_over_the_configured_one() {
let directory = scratch("explicit-wins");
let explicit = directory.join("chosen.png");
std::fs::write(&explicit, b"chosen").unwrap();
let source = FileLogoSource::new(Some(explicit), Some(write_logo(&directory))).unwrap();
assert_eq!(source.load().unwrap(), Some(b"chosen".as_slice()));
}
#[test]
fn reads_the_configured_path() {
let logo = write_logo(&scratch("configured"));
let source = FileLogoSource::new(None, Some(logo)).unwrap();
assert_eq!(source.load().unwrap(), Some(LOGO_BYTES));
}
#[test]
fn a_missing_configured_path_is_not_an_error() {
let missing = scratch("configured-missing").join("absent.png");
let source = FileLogoSource::new(None, Some(missing)).unwrap();
assert_eq!(source.load().unwrap(), None);
}
#[test]
fn no_paths_at_all_yields_nothing() {
let source = FileLogoSource::new(None, None).unwrap();
assert_eq!(source.load().unwrap(), None);
}
#[test]
fn an_unreadable_configured_path_is_an_error() {
let directory = scratch("configured-unreadable");
std::fs::create_dir_all(directory.join("logo.png")).unwrap();
assert!(FileLogoSource::new(None, Some(directory.join("logo.png"))).is_err());
}
#[test]
fn an_empty_dvd_logo_variable_is_treated_as_unset() {
let env = env_of(&[("DVD_LOGO", ""), ("HOME", "")]);
let source = FileLogoSource::from_environment(Platform::Xdg, &env).unwrap();
assert_eq!(source.load().unwrap(), None);
}
#[test]
fn a_set_dvd_logo_variable_still_wins() {
let logo = write_logo(&scratch("env-explicit"));
let env = env_of(&[("DVD_LOGO", logo.to_str().unwrap()), ("HOME", "")]);
let source = FileLogoSource::from_environment(Platform::Xdg, &env).unwrap();
assert_eq!(source.load().unwrap(), Some(LOGO_BYTES));
}

View File

@@ -0,0 +1,16 @@
[package]
name = "wayland"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
domain = { workspace = true }
application = { workspace = true }
wayland-client = "0.31"
smithay-client-toolkit = { version = "0.21", default-features = false }
tiny-skia = "0.12"
rustix = { version = "1.1", features = ["event"] }
signal-hook = { version = "0.4", default-features = false }

View File

@@ -0,0 +1,219 @@
use std::io::{ErrorKind, Read};
use application::{ApplicationError, DisplayOverlayPort};
use domain::Dimensions;
use rustix::event::{PollFd, PollFlags, poll};
use smithay_client_toolkit::{
compositor::{CompositorState, Region},
output::OutputState,
registry::RegistryState,
shell::{
WaylandSurface,
wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerShell, LayerSurface},
},
shm::Shm,
};
use wayland_client::{
Connection, EventQueue, QueueHandle, globals::registry_queue_init,
protocol::wl_surface::WlSurface,
};
use crate::state::WaylandState;
const DEFAULT_SURFACE_SIZE: Dimensions = match Dimensions::new(800, 600) {
Some(size) => size,
None => panic!("default surface size must be non-zero"),
};
const SHUTDOWN_SIGNALS: [i32; 2] = [signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM];
pub struct WaylandDisplay {
connection: Connection,
event_queue: EventQueue<WaylandState>,
qh: QueueHandle<WaylandState>,
state: WaylandState,
layer_surface: LayerSurface,
signal_pipe: std::io::PipeReader,
}
impl WaylandDisplay {
pub fn new() -> Result<Self, ApplicationError> {
let connection = Connection::connect_to_env().map_err(init_err)?;
let (globals, mut event_queue) = registry_queue_init(&connection).map_err(init_err)?;
let qh = event_queue.handle();
let compositor_state = CompositorState::bind(&globals, &qh).map_err(init_err)?;
let layer_shell = LayerShell::bind(&globals, &qh).map_err(init_err)?;
let shm = Shm::bind(&globals, &qh).map_err(init_err)?;
let mut state = WaylandState {
registry_state: RegistryState::new(&globals),
output_state: OutputState::new(&globals, &qh),
compositor_state,
layer_shell,
shm,
surface_dimensions: DEFAULT_SURFACE_SIZE,
configured: false,
redraw_requested: false,
exit: false,
};
let surface = state.compositor_state.create_surface(&qh);
let empty_region = Region::new(&state.compositor_state).map_err(init_err)?;
surface.set_input_region(Some(empty_region.wl_region()));
drop(empty_region);
let layer_surface = state.layer_shell.create_layer_surface(
&qh,
surface,
Layer::Overlay,
Some("dvd-screensaver"),
None,
);
layer_surface.set_anchor(Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT);
layer_surface.set_keyboard_interactivity(KeyboardInteractivity::None);
layer_surface.commit();
let signal_pipe = install_signal_handlers()?;
while !state.configured && !state.exit {
event_queue
.blocking_dispatch(&mut state)
.map_err(init_err)?;
}
Ok(WaylandDisplay {
connection,
event_queue,
qh,
state,
layer_surface,
signal_pipe,
})
}
pub fn wl_surface(&self) -> WlSurface {
self.layer_surface.wl_surface().clone()
}
pub fn qh(&self) -> &QueueHandle<WaylandState> {
&self.qh
}
pub fn shm(&self) -> &Shm {
&self.state.shm
}
fn dispatch_blocking(&mut self) -> Result<(), ApplicationError> {
if self
.event_queue
.dispatch_pending(&mut self.state)
.map_err(loop_err)?
> 0
{
return Ok(());
}
let Some(read_guard) = self.event_queue.prepare_read() else {
self.event_queue
.dispatch_pending(&mut self.state)
.map_err(loop_err)?;
return Ok(());
};
self.connection.flush().map_err(loop_err)?;
let wayland_fd = read_guard.connection_fd();
let mut fds = [
PollFd::new(&wayland_fd, PollFlags::IN),
PollFd::new(&self.signal_pipe, PollFlags::IN),
];
match poll(&mut fds, None) {
Ok(_) => {}
Err(rustix::io::Errno::INTR) => return Ok(()),
Err(e) => return Err(loop_err(e)),
}
let signalled = fds[1].revents().intersects(PollFlags::IN);
let readable = fds[0].revents().intersects(PollFlags::IN);
if signalled {
self.drain_signal_pipe();
self.state.exit = true;
return Ok(());
}
if readable {
match read_guard.read() {
Ok(_) => {}
Err(e) if is_would_block(&e) => {}
Err(e) => return Err(loop_err(e)),
}
self.event_queue
.dispatch_pending(&mut self.state)
.map_err(loop_err)?;
}
Ok(())
}
fn drain_signal_pipe(&mut self) {
let mut scratch = [0u8; 16];
let _ = self.signal_pipe.read(&mut scratch);
}
}
impl DisplayOverlayPort for WaylandDisplay {
fn screen_dimensions(&self) -> Dimensions {
self.state.surface_dimensions
}
fn wait_for_frame(&mut self) -> Result<bool, ApplicationError> {
loop {
if self.state.exit {
return Ok(false);
}
if self.state.redraw_requested {
self.state.redraw_requested = false;
return Ok(true);
}
self.dispatch_blocking()?;
}
}
}
impl Drop for WaylandDisplay {
fn drop(&mut self) {
self.layer_surface.wl_surface().attach(None, 0, 0);
self.layer_surface.commit();
let _ = self.connection.flush();
}
}
fn install_signal_handlers() -> Result<std::io::PipeReader, ApplicationError> {
let (reader, writer) = std::io::pipe().map_err(init_err)?;
for signal in SHUTDOWN_SIGNALS {
signal_hook::low_level::pipe::register(signal, writer.try_clone().map_err(init_err)?)
.map_err(init_err)?;
}
Ok(reader)
}
fn is_would_block(err: &wayland_client::backend::WaylandError) -> bool {
matches!(err, wayland_client::backend::WaylandError::Io(e) if e.kind() == ErrorKind::WouldBlock)
}
fn init_err(e: impl std::fmt::Display) -> ApplicationError {
ApplicationError::display_initialization(e)
}
fn loop_err(e: impl std::fmt::Display) -> ApplicationError {
ApplicationError::event_loop(e)
}

View File

@@ -0,0 +1,123 @@
use application::{ApplicationError, LogoSourcePort};
use domain::{Color, Dimensions};
use tiny_skia::{FilterQuality, Pixmap, PixmapMut, PixmapPaint, PremultipliedColorU8, Transform};
pub struct LogoImage {
source: Pixmap,
scaled: Option<(Dimensions, Pixmap)>,
tinted: Vec<(Dimensions, Color, Pixmap)>,
}
impl LogoImage {
pub fn load(source: &impl LogoSourcePort) -> Result<Option<Self>, ApplicationError> {
let Some(encoded) = source.load()? else {
return Ok(None);
};
let source = Pixmap::decode_png(encoded)
.map_err(|e| ApplicationError::logo_source(format_args!("not a usable PNG: {e}")))?;
Ok(Some(LogoImage {
source,
scaled: None,
tinted: Vec::new(),
}))
}
pub fn tinted(&mut self, size: Dimensions, color: Color) -> Option<&Pixmap> {
if let Some(index) = self
.tinted
.iter()
.position(|(cached, tint, _)| *cached == size && *tint == color)
{
return Some(&self.tinted[index].2);
}
let mut pixmap = self.scale_to(size)?.clone();
tint(&mut pixmap, color);
self.tinted.retain(|(cached, _, _)| *cached == size);
self.tinted.push((size, color, pixmap));
self.tinted.last().map(|(_, _, pixmap)| pixmap)
}
fn scale_to(&mut self, size: Dimensions) -> Option<&Pixmap> {
if !self
.scaled
.as_ref()
.is_some_and(|(cached, _)| *cached == size)
{
let mut target = Pixmap::new(size.width(), size.height())?;
target.draw_pixmap(
0,
0,
self.source.as_ref(),
&PixmapPaint {
quality: FilterQuality::Bicubic,
..Default::default()
},
Transform::from_scale(
size.width() as f32 / self.source.width() as f32,
size.height() as f32 / self.source.height() as f32,
),
None,
);
self.scaled = Some((size, target));
}
self.scaled.as_ref().map(|(_, pixmap)| pixmap)
}
}
fn tint(pixmap: &mut Pixmap, color: Color) {
let (r, g, b) = surface_channels(color);
for pixel in pixmap.pixels_mut() {
let alpha = pixel.alpha();
*pixel = PremultipliedColorU8::from_rgba(
premultiply(r, alpha),
premultiply(g, alpha),
premultiply(b, alpha),
alpha,
)
.unwrap_or(PremultipliedColorU8::TRANSPARENT);
}
}
fn premultiply(channel: u8, alpha: u8) -> u8 {
((channel as u16 * alpha as u16) / 255) as u8
}
pub fn blit(destination: &mut PixmapMut, x: i32, y: i32, source: &Pixmap) {
let width = destination.width() as i32;
let height = destination.height() as i32;
let left = x.max(0);
let top = y.max(0);
let right = (x + source.width() as i32).min(width);
let bottom = (y + source.height() as i32).min(height);
if left >= right || top >= bottom {
return;
}
let span = ((right - left) * 4) as usize;
let source_width = source.width() as usize;
let source_data = source.data();
let destination_data = destination.data_mut();
for row in top..bottom {
let from = (((row - y) as usize) * source_width + (left - x) as usize) * 4;
let to = ((row as usize) * width as usize + left as usize) * 4;
destination_data[to..to + span].copy_from_slice(&source_data[from..from + span]);
}
}
pub fn surface_channels(color: Color) -> (u8, u8, u8) {
(color.b, color.g, color.r)
}

View File

@@ -0,0 +1,8 @@
mod display;
mod image;
mod renderer;
mod state;
pub use display::WaylandDisplay;
pub use renderer::SoftwareRenderer;
pub use state::WaylandState;

View File

@@ -0,0 +1,304 @@
use application::{ApplicationError, DisplayOverlayPort, LogoSourcePort, RendererPort};
use domain::{Dimensions, ScreensaverScene, Sprite, SpriteKind};
use smithay_client_toolkit::{
compositor::FrameCallbackData,
shm::slot::{Buffer, SlotPool},
};
use tiny_skia::{BlendMode, Color, IntRect, Paint, PixmapMut, Rect, Transform};
use wayland_client::{QueueHandle, protocol::wl_shm, protocol::wl_surface::WlSurface};
use crate::{
display::WaylandDisplay,
image::{LogoImage, blit, surface_channels},
state::WaylandState,
};
#[cfg(target_endian = "big")]
compile_error!("Argb8888 byte order and tiny-skia premultiplication assume a little-endian target");
const BYTES_PER_PIXEL: u32 = 4;
const FORMAT: wl_shm::Format = wl_shm::Format::Argb8888;
const BUFFER_COUNT: usize = 2;
#[derive(Clone, Copy)]
struct BufferSpec {
size: Dimensions,
stride: i32,
}
impl BufferSpec {
fn new(size: Dimensions) -> Self {
BufferSpec {
size,
stride: (size.width() * BYTES_PER_PIXEL) as i32,
}
}
fn width(&self) -> i32 {
self.size.width() as i32
}
fn height(&self) -> i32 {
self.size.height() as i32
}
fn byte_len(&self) -> usize {
self.size.height() as usize * self.stride as usize
}
fn matches(&self, buffer: &Buffer) -> bool {
buffer.stride() == self.stride && buffer.height() == self.height()
}
}
enum Clear {
Everything,
Region(IntRect),
Nothing,
}
struct Frame {
buffer: Buffer,
painted: Option<IntRect>,
}
impl Frame {
fn new(pool: &mut SlotPool, spec: BufferSpec) -> Result<Self, ApplicationError> {
let (buffer, _) = pool
.create_buffer(spec.width(), spec.height(), spec.stride, FORMAT)
.map_err(render_err)?;
Ok(Frame {
buffer,
painted: None,
})
}
}
pub struct SoftwareRenderer {
surface: WlSurface,
qh: QueueHandle<WaylandState>,
pool: SlotPool,
logo: Option<LogoImage>,
frames: [Frame; BUFFER_COUNT],
next: usize,
presented: Option<IntRect>,
committed: bool,
}
impl SoftwareRenderer {
pub fn new(
display: &WaylandDisplay,
logo: &impl LogoSourcePort,
) -> Result<Self, ApplicationError> {
let spec = BufferSpec::new(display.screen_dimensions());
let mut pool =
SlotPool::new(BUFFER_COUNT * spec.byte_len(), display.shm()).map_err(render_err)?;
let frames: [Frame; BUFFER_COUNT] = (0..BUFFER_COUNT)
.map(|_| Frame::new(&mut pool, spec))
.collect::<Result<Vec<_>, _>>()?
.try_into()
.map_err(|_| render_err("buffer count mismatch"))?;
Ok(SoftwareRenderer {
surface: display.wl_surface(),
qh: display.qh().clone(),
pool,
logo: LogoImage::load(logo)?,
frames,
next: 0,
presented: None,
committed: false,
})
}
fn advance(&mut self) -> usize {
let index = self.next;
self.next = (self.next + 1) % self.frames.len();
index
}
fn prepare_buffer(
&mut self,
index: usize,
spec: BufferSpec,
) -> Result<Clear, ApplicationError> {
let pool = &mut self.pool;
let frame = &mut self.frames[index];
if spec.matches(&frame.buffer) && pool.canvas(&frame.buffer).is_some() {
return Ok(match frame.painted.take() {
Some(region) => Clear::Region(region),
None => Clear::Nothing,
});
}
*frame = Frame::new(pool, spec)?;
Ok(Clear::Everything)
}
fn paint(
&mut self,
index: usize,
spec: BufferSpec,
scene: &ScreensaverScene,
clear: Clear,
) -> Result<Option<IntRect>, ApplicationError> {
let pool = &mut self.pool;
let logo = &mut self.logo;
let frame = &self.frames[index];
let canvas = pool
.canvas(&frame.buffer)
.ok_or_else(|| render_err("buffer still held by compositor"))?;
let mut pixmap = PixmapMut::from_bytes(canvas, spec.size.width(), spec.size.height())
.ok_or_else(|| render_err("canvas does not match surface size"))?;
let mut paint = Paint {
anti_alias: false,
..Default::default()
};
match clear {
Clear::Everything => pixmap.fill(Color::TRANSPARENT),
Clear::Region(region) => {
paint.blend_mode = BlendMode::Clear;
pixmap.fill_rect(int_to_rect(region), &paint, Transform::identity(), None);
}
Clear::Nothing => {}
}
let mut painted: Option<Rect> = None;
paint.blend_mode = BlendMode::SourceOver;
for sprite in scene.sprites() {
let Some(rect) = sprite_rect(&sprite) else {
continue;
};
match sprite.kind() {
SpriteKind::Logo => {
let tinted = logo
.as_mut()
.and_then(|image| image.tinted(sprite.dimensions(), sprite.color()));
match tinted {
Some(tinted) => blit(&mut pixmap, rect.x() as i32, rect.y() as i32, tinted),
None => {
paint.set_color(paint_color(sprite.color()));
pixmap.fill_rect(rect, &paint, Transform::identity(), None);
}
}
}
SpriteKind::Sparkle => {
paint.set_color(paint_color(sprite.color()));
pixmap.fill_rect(rect, &paint, Transform::identity(), None);
}
}
painted = Some(match painted {
Some(acc) => acc.join(&rect).unwrap_or(acc),
None => rect,
});
}
Ok(painted
.and_then(|rect| rect.round_out())
.and_then(|rect| clamp_to_surface(rect, spec)))
}
fn damage_moved_logo(&self, previous: Option<IntRect>, drawn: Option<IntRect>) {
for rect in [previous, drawn].into_iter().flatten() {
self.surface.damage_buffer(
rect.x(),
rect.y(),
rect.width() as i32,
rect.height() as i32,
);
}
}
fn present(&self, index: usize) -> Result<(), ApplicationError> {
self.surface
.frame(&self.qh, FrameCallbackData(self.surface.clone()));
self.frames[index]
.buffer
.attach_to(&self.surface)
.map_err(render_err)?;
self.surface.commit();
Ok(())
}
}
impl RendererPort for SoftwareRenderer {
fn render_frame(
&mut self,
scene: &ScreensaverScene,
screen_size: Dimensions,
) -> Result<(), ApplicationError> {
let spec = BufferSpec::new(screen_size);
let index = self.advance();
let clear = self.prepare_buffer(index, spec)?;
let reallocated = matches!(clear, Clear::Everything);
let drawn = self.paint(index, spec, scene, clear)?;
self.frames[index].painted = drawn;
let previous = std::mem::replace(&mut self.presented, drawn);
if reallocated || !self.committed {
self.surface
.damage_buffer(0, 0, spec.width(), spec.height());
} else {
self.damage_moved_logo(previous, drawn);
}
self.present(index)?;
self.committed = true;
Ok(())
}
}
fn sprite_rect(sprite: &Sprite) -> Option<Rect> {
let position = sprite.position();
let dimensions = sprite.dimensions();
Rect::from_xywh(
position.x(),
position.y(),
dimensions.width() as f32,
dimensions.height() as f32,
)
}
fn clamp_to_surface(rect: IntRect, spec: BufferSpec) -> Option<IntRect> {
IntRect::from_ltrb(0, 0, spec.width(), spec.height())
.and_then(|surface| rect.intersect(&surface))
}
fn paint_color(color: domain::Color) -> Color {
let (r, g, b) = surface_channels(color);
Color::from_rgba8(r, g, b, color.a)
}
fn int_to_rect(rect: IntRect) -> Rect {
Rect::from_ltrb(
rect.left() as f32,
rect.top() as f32,
rect.right() as f32,
rect.bottom() as f32,
)
.expect("IntRect always has positive extents")
}
fn render_err(e: impl std::fmt::Display) -> ApplicationError {
ApplicationError::rendering_failed(e)
}

View File

@@ -0,0 +1,155 @@
use domain::Dimensions;
use smithay_client_toolkit::{
compositor::{CompositorHandler, CompositorState},
delegate_dispatch2, delegate_registry,
output::{OutputHandler, OutputState},
registry::{ProvidesRegistryState, RegistryState},
registry_handlers,
shell::wlr_layer::{LayerShell, LayerShellHandler, LayerSurface, LayerSurfaceConfigure},
shm::{Shm, ShmHandler},
};
use wayland_client::{
Connection, QueueHandle,
protocol::{wl_output, wl_surface},
};
pub struct WaylandState {
pub registry_state: RegistryState,
pub output_state: OutputState,
pub compositor_state: CompositorState,
pub layer_shell: LayerShell,
pub shm: Shm,
pub surface_dimensions: Dimensions,
pub configured: bool,
pub redraw_requested: bool,
pub exit: bool,
}
impl CompositorHandler for WaylandState {
fn scale_factor_changed(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_new_factor: i32,
) {
}
fn transform_changed(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_new_transform: wl_output::Transform,
) {
}
fn frame(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_time: u32,
) {
self.redraw_requested = true;
}
fn surface_enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_output: &wl_output::WlOutput,
) {
}
fn surface_leave(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_surface: &wl_surface::WlSurface,
_output: &wl_output::WlOutput,
) {
}
}
impl OutputHandler for WaylandState {
fn output_state(&mut self) -> &mut OutputState {
&mut self.output_state
}
fn new_output(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_output: wl_output::WlOutput,
) {
}
fn update_output(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_output: wl_output::WlOutput,
) {
}
fn output_destroyed(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_output: wl_output::WlOutput,
) {
}
}
impl LayerShellHandler for WaylandState {
fn closed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _layer: &LayerSurface) {
self.exit = true;
}
fn configure(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_layer: &LayerSurface,
configure: LayerSurfaceConfigure,
_serial: u32,
) {
let (width, height) = configure.new_size;
let current = self.surface_dimensions;
let width = if width == 0 { current.width() } else { width };
let height = if height == 0 {
current.height()
} else {
height
};
if let Some(size) = Dimensions::new(width, height) {
self.surface_dimensions = size;
}
self.configured = true;
self.redraw_requested = true;
}
}
impl ShmHandler for WaylandState {
fn shm_state(&mut self) -> &mut Shm {
&mut self.shm
}
}
impl ProvidesRegistryState for WaylandState {
fn registry(&mut self) -> &mut RegistryState {
&mut self.registry_state
}
registry_handlers![OutputState];
}
delegate_registry!(WaylandState);
delegate_dispatch2!(WaylandState);

View File

@@ -0,0 +1,11 @@
[package]
name = "application"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
domain = { workspace = true }
heapless = { version = "0.9.3", default-features = false }
thiserror = { workspace = true }

View File

@@ -0,0 +1,55 @@
use core::fmt::{self, Display, Write};
pub type Message = heapless::String<104, u8>;
#[derive(Debug, thiserror::Error)]
pub enum ApplicationError {
#[error("Failed to initialize display: {0}")]
DisplayInitialization(Message),
#[error("Display event loop failure: {0}")]
EventLoop(Message),
#[error("Render pipeline failure: {0}")]
RenderingFailed(Message),
#[error("Failed to load logo: {0}")]
LogoSource(Message),
#[error("Domain configuration error: {0}")]
DomainError(#[from] domain::DomainError),
}
impl ApplicationError {
pub fn display_initialization(reason: impl Display) -> Self {
Self::DisplayInitialization(message(reason))
}
pub fn event_loop(reason: impl Display) -> Self {
Self::EventLoop(message(reason))
}
pub fn rendering_failed(reason: impl Display) -> Self {
Self::RenderingFailed(message(reason))
}
pub fn logo_source(reason: impl Display) -> Self {
Self::LogoSource(message(reason))
}
}
fn message(reason: impl Display) -> Message {
let mut buffer = Message::new();
let _ = write!(Truncating(&mut buffer), "{reason}");
buffer
}
struct Truncating<'a>(&'a mut Message);
impl Write for Truncating<'_> {
fn write_str(&mut self, text: &str) -> fmt::Result {
for character in text.chars() {
if self.0.push(character).is_err() {
break;
}
}
Ok(())
}
}

View File

@@ -0,0 +1,9 @@
#![no_std]
mod errors;
mod ports;
mod use_cases;
pub use errors::{ApplicationError, Message};
pub use ports::{DisplayOverlayPort, LogoSourcePort, RendererPort};
pub use use_cases::{run, step};

View File

@@ -0,0 +1,20 @@
use domain::{Dimensions, ScreensaverScene};
use crate::ApplicationError;
pub trait DisplayOverlayPort {
fn screen_dimensions(&self) -> Dimensions;
fn wait_for_frame(&mut self) -> Result<bool, ApplicationError>;
}
pub trait RendererPort {
fn render_frame(
&mut self,
scene: &ScreensaverScene,
screen_size: Dimensions,
) -> Result<(), ApplicationError>;
}
pub trait LogoSourcePort {
fn load(&self) -> Result<Option<&[u8]>, ApplicationError>;
}

View File

@@ -0,0 +1,37 @@
use domain::ScreensaverScene;
use crate::{
errors::ApplicationError,
ports::{DisplayOverlayPort, RendererPort},
};
pub fn step<D, R>(
display: &mut D,
renderer: &mut R,
scene: &mut ScreensaverScene,
) -> Result<(), ApplicationError>
where
D: DisplayOverlayPort,
R: RendererPort,
{
let bounds = display.screen_dimensions();
scene.tick(bounds);
renderer.render_frame(scene, bounds)
}
pub fn run<D, R>(
display: &mut D,
renderer: &mut R,
scene: &mut ScreensaverScene,
) -> Result<(), ApplicationError>
where
D: DisplayOverlayPort,
R: RendererPort,
{
while display.wait_for_frame()? {
step(display, renderer, scene)?;
}
Ok(())
}

16
crates/bin/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "bin"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[[bin]]
name = "dvd-thing"
path = "src/main.rs"
[dependencies]
domain = { workspace = true }
application = { workspace = true }
wayland = { workspace = true }
assets = { workspace = true }

39
crates/bin/src/main.rs Normal file
View File

@@ -0,0 +1,39 @@
use std::process::ExitCode;
use application::DisplayOverlayPort;
use assets::FileLogoSource;
use domain::{Color, Dimensions, ScreensaverScene, Vector2D};
use wayland::{SoftwareRenderer, WaylandDisplay};
const LOGO: Dimensions = match Dimensions::new(320, 198) {
Some(size) => size,
None => panic!("logo dimensions must be non-zero"),
};
const SPEED: Vector2D = match Vector2D::new(3.0, 2.0) {
Some(speed) => speed,
None => panic!("logo speed must be finite"),
};
const START_COLOR: Color = Color {
r: 255,
g: 0,
b: 0,
a: 255,
};
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("dvd-thing: {e}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), application::ApplicationError> {
let mut display = WaylandDisplay::new()?;
let mut renderer = SoftwareRenderer::new(&display, &FileLogoSource::from_env()?)?;
let mut scene = ScreensaverScene::new(LOGO, SPEED, START_COLOR, display.screen_dimensions())?;
application::run(&mut display, &mut renderer, &mut scene)
}

10
crates/domain/Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "domain"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
thiserror = { workspace = true }
libm = { version = "0.2", default-features = false }

View File

@@ -0,0 +1,14 @@
use thiserror::Error;
#[derive(Error, Debug, PartialEq)]
pub enum DomainError {
#[error(
"Logo dimension ({width}x{height}) exceeds boundary screen size ({bounds_w}x{bounds_h})"
)]
InvalidDimensions {
width: u32,
height: u32,
bounds_w: u32,
bounds_h: u32,
},
}

13
crates/domain/src/lib.rs Normal file
View File

@@ -0,0 +1,13 @@
#![no_std]
mod errors;
mod logo;
mod scene;
mod sparkle;
mod sprite;
mod value_objects;
pub use errors::DomainError;
pub use scene::ScreensaverScene;
pub use sprite::{Sprite, SpriteKind};
pub use value_objects::{Color, Dimensions, Point2D, Vector2D};

125
crates/domain/src/logo.rs Normal file
View File

@@ -0,0 +1,125 @@
use crate::{Color, Dimensions, Point2D, Sprite, SpriteKind, Vector2D};
struct Axis {
position: f32,
impact: Option<f32>,
bounced: bool,
}
fn advance(position: f32, velocity: f32, extent: u32, bound: u32) -> Axis {
if extent > bound {
return Axis {
position: 0.0,
impact: None,
bounced: false,
};
}
let next = position + velocity;
let impact = if next < 0.0 {
Some(0.0)
} else if next + extent as f32 >= bound as f32 {
Some(bound as f32)
} else {
None
};
match impact {
Some(_) => Axis {
position: position - velocity,
impact,
bounced: true,
},
None => Axis {
position: next,
impact: None,
bounced: false,
},
}
}
pub(crate) struct BouncingLogo {
position: Point2D,
velocity: Vector2D,
dimensions: Dimensions,
color: Color,
}
impl BouncingLogo {
pub(crate) const fn new(
position: Point2D,
velocity: Vector2D,
dimensions: Dimensions,
color: Color,
) -> Self {
Self {
position,
velocity,
dimensions,
color,
}
}
pub(crate) fn tick(&mut self, bounds: Dimensions) -> Option<Point2D> {
let x = advance(
self.position.x(),
self.velocity.dx(),
self.dimensions.width(),
bounds.width(),
);
let y = advance(
self.position.y(),
self.velocity.dy(),
self.dimensions.height(),
bounds.height(),
);
if x.bounced {
self.velocity = self.velocity.reflected_x();
}
if y.bounced {
self.velocity = self.velocity.reflected_y();
}
if let Some(position) = Point2D::new(x.position, y.position) {
self.position = position;
}
if x.bounced || y.bounced {
self.mutate_color();
}
match (x.impact, y.impact) {
(Some(corner_x), Some(corner_y)) => Point2D::new(corner_x, corner_y),
_ => None,
}
}
fn mutate_color(&mut self) {
self.color = match (self.color.r, self.color.g, self.color.b) {
(255, 0, 0) => Color {
r: 0,
g: 255,
b: 0,
a: 255,
},
(0, 255, 0) => Color {
r: 0,
g: 0,
b: 255,
a: 255,
},
_ => Color {
r: 255,
g: 0,
b: 0,
a: 255,
},
};
}
pub(crate) const fn sprite(&self) -> Sprite {
Sprite::new(SpriteKind::Logo, self.position, self.dimensions, self.color)
}
}

View File

@@ -0,0 +1,73 @@
use crate::{
Color, Dimensions, DomainError, Point2D, Sprite, Vector2D,
logo::BouncingLogo,
sparkle::{self, Sparkle},
};
const MAX_SPARKLES: usize = sparkle::BURST as usize * (sparkle::LIFETIME_FRAMES as usize + 1);
pub struct ScreensaverScene {
logo: BouncingLogo,
sparkles: [Option<Sparkle>; MAX_SPARKLES],
}
impl ScreensaverScene {
pub fn new(
logo: Dimensions,
speed: Vector2D,
color: Color,
bounds: Dimensions,
) -> Result<Self, DomainError> {
if logo.width() > bounds.width() || logo.height() > bounds.height() {
return Err(DomainError::InvalidDimensions {
width: logo.width(),
height: logo.height(),
bounds_w: bounds.width(),
bounds_h: bounds.height(),
});
}
let position = Point2D::new(
(bounds.width() - logo.width()) as f32 / 2.0,
(bounds.height() - logo.height()) as f32 / 2.0,
)
.unwrap_or_default();
Ok(Self {
logo: BouncingLogo::new(position, speed, logo, color),
sparkles: [const { None }; MAX_SPARKLES],
})
}
pub fn tick(&mut self, bounds: Dimensions) {
for slot in &mut self.sparkles {
if let Some(sparkle) = slot {
sparkle.tick();
if !sparkle.is_alive() {
*slot = None;
}
}
}
if let Some(corner) = self.logo.tick(bounds) {
self.spawn(Sparkle::burst(corner));
}
}
pub fn sprites(&self) -> impl Iterator<Item = Sprite> + '_ {
core::iter::once(self.logo.sprite())
.chain(self.sparkles.iter().flatten().map(Sparkle::sprite))
}
fn spawn(&mut self, burst: impl Iterator<Item = Sparkle>) {
let mut free = self.sparkles.iter_mut().filter(|slot| slot.is_none());
for sparkle in burst {
match free.next() {
Some(slot) => *slot = Some(sparkle),
None => break,
}
}
}
}

View File

@@ -0,0 +1,60 @@
use crate::{Color, Dimensions, Point2D, Sprite, SpriteKind, Vector2D};
pub(crate) const BURST: u32 = 12;
const SPEED: f32 = 3.0;
pub(crate) const LIFETIME_FRAMES: u8 = 8;
const FADE_PER_FRAME: u8 = u8::MAX / LIFETIME_FRAMES;
const SIZE: Dimensions = match Dimensions::new(8, 8) {
Some(size) => size,
None => panic!("sparkle size must be non-zero"),
};
const COLOR: Color = Color {
r: 255,
g: 215,
b: 0,
a: 255,
};
pub(crate) struct Sparkle {
position: Point2D,
velocity: Vector2D,
dimensions: Dimensions,
color: Color,
}
impl Sparkle {
pub(crate) fn burst(origin: Point2D) -> impl Iterator<Item = Sparkle> {
(0..BURST).filter_map(move |step| {
let angle = step as f32 * (core::f32::consts::TAU / BURST as f32);
let velocity = Vector2D::new(libm::cosf(angle) * SPEED, libm::sinf(angle) * SPEED)?;
Some(Sparkle {
position: origin,
velocity,
dimensions: SIZE,
color: COLOR,
})
})
}
pub(crate) fn tick(&mut self) {
if let Some(position) = self.position.translated(self.velocity) {
self.position = position;
}
self.color.a = self.color.a.saturating_sub(FADE_PER_FRAME);
}
pub(crate) fn is_alive(&self) -> bool {
self.color.a > 0
}
pub(crate) const fn sprite(&self) -> Sprite {
Sprite::new(
SpriteKind::Sparkle,
self.position,
self.dimensions,
self.color,
)
}
}

View File

@@ -0,0 +1,47 @@
use crate::{Color, Dimensions, Point2D};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpriteKind {
Logo,
Sparkle,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Sprite {
kind: SpriteKind,
position: Point2D,
dimensions: Dimensions,
color: Color,
}
impl Sprite {
pub(crate) const fn new(
kind: SpriteKind,
position: Point2D,
dimensions: Dimensions,
color: Color,
) -> Self {
Self {
kind,
position,
dimensions,
color,
}
}
pub const fn kind(&self) -> SpriteKind {
self.kind
}
pub const fn position(&self) -> Point2D {
self.position
}
pub const fn dimensions(&self) -> Dimensions {
self.dimensions
}
pub const fn color(&self) -> Color {
self.color
}
}

View File

@@ -0,0 +1,97 @@
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Point2D {
x: f32,
y: f32,
}
impl Point2D {
pub const fn new(x: f32, y: f32) -> Option<Self> {
if x.is_finite() && y.is_finite() {
Some(Self { x, y })
} else {
None
}
}
pub const fn x(&self) -> f32 {
self.x
}
pub const fn y(&self) -> f32 {
self.y
}
pub(crate) fn translated(self, by: Vector2D) -> Option<Self> {
Self::new(self.x + by.dx, self.y + by.dy)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Vector2D {
dx: f32,
dy: f32,
}
impl Vector2D {
pub const fn new(dx: f32, dy: f32) -> Option<Self> {
if dx.is_finite() && dy.is_finite() {
Some(Self { dx, dy })
} else {
None
}
}
pub const fn dx(&self) -> f32 {
self.dx
}
pub const fn dy(&self) -> f32 {
self.dy
}
pub(crate) const fn reflected_x(self) -> Self {
Self {
dx: -self.dx,
dy: self.dy,
}
}
pub(crate) const fn reflected_y(self) -> Self {
Self {
dx: self.dx,
dy: -self.dy,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Dimensions {
width: u32,
height: u32,
}
impl Dimensions {
pub const fn new(width: u32, height: u32) -> Option<Self> {
if width == 0 || height == 0 {
None
} else {
Some(Self { width, height })
}
}
pub const fn width(&self) -> u32 {
self.width
}
pub const fn height(&self) -> u32 {
self.height
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}

View File

@@ -0,0 +1,62 @@
#![allow(dead_code)]
use domain::{Color, Dimensions, DomainError, Point2D, ScreensaverScene, Sprite, Vector2D};
pub const LOGO: Dimensions = size(200, 100);
pub const ROOMY: Dimensions = size(800, 600);
pub const TINY: Dimensions = size(50, 50);
pub const SNUG: Dimensions = size(LOGO.width() + 2, LOGO.height() + 2);
pub const EDGE_ONLY: Dimensions = size(LOGO.width() + 2, ROOMY.height());
pub const SPEED: Vector2D = velocity(3.0, 2.0);
pub const RED: Color = Color {
r: 255,
g: 0,
b: 0,
a: 255,
};
pub const fn size(width: u32, height: u32) -> Dimensions {
match Dimensions::new(width, height) {
Some(size) => size,
None => panic!("test dimensions must be non-zero"),
}
}
const fn velocity(dx: f32, dy: f32) -> Vector2D {
match Vector2D::new(dx, dy) {
Some(velocity) => velocity,
None => panic!("test velocity must be finite"),
}
}
pub fn point(x: f32, y: f32) -> Point2D {
Point2D::new(x, y).expect("test point must be finite")
}
pub fn try_scene(logo: Dimensions, bounds: Dimensions) -> Result<ScreensaverScene, DomainError> {
ScreensaverScene::new(logo, SPEED, RED, bounds)
}
pub fn scene_in(bounds: Dimensions) -> ScreensaverScene {
try_scene(LOGO, bounds).expect("LOGO must fit the test bounds")
}
pub fn advance(scene: &mut ScreensaverScene, bounds: Dimensions, frames: u32) {
for _ in 0..frames {
scene.tick(bounds);
}
}
pub fn sprite_count(scene: &ScreensaverScene) -> usize {
scene.sprites().count()
}
pub fn logo_sprite(scene: &ScreensaverScene) -> Sprite {
scene.sprites().next().expect("the logo is always present")
}
pub fn bottom_right(bounds: Dimensions) -> Point2D {
point(bounds.width() as f32, bounds.height() as f32)
}

View File

@@ -0,0 +1,37 @@
mod common;
use common::{LOGO, ROOMY, scene_in, size, sprite_count, try_scene};
use domain::{Dimensions, Point2D, Vector2D};
#[test]
fn dimensions_reject_zero_extents() {
assert!(Dimensions::new(0, 10).is_none());
assert!(Dimensions::new(10, 0).is_none());
assert!(Dimensions::new(10, 10).is_some());
}
#[test]
fn points_reject_non_finite_coordinates() {
assert!(Point2D::new(f32::NAN, 0.0).is_none());
assert!(Point2D::new(0.0, f32::INFINITY).is_none());
assert!(Point2D::new(-1.0, 1.0).is_some());
}
#[test]
fn vectors_reject_non_finite_components() {
assert!(Vector2D::new(f32::NAN, 1.0).is_none());
assert!(Vector2D::new(1.0, f32::NEG_INFINITY).is_none());
assert!(Vector2D::new(0.0, 0.0).is_some());
}
#[test]
fn the_logo_must_fit_inside_the_bounds() {
assert!(try_scene(LOGO, LOGO).is_ok());
assert!(try_scene(size(LOGO.width() + 1, LOGO.height()), LOGO).is_err());
assert!(try_scene(size(LOGO.width(), LOGO.height() + 1), LOGO).is_err());
}
#[test]
fn a_new_scene_holds_only_the_logo() {
assert_eq!(sprite_count(&scene_in(ROOMY)), 1);
}

View File

@@ -0,0 +1,75 @@
mod common;
use common::{
EDGE_ONLY, ROOMY, SNUG, TINY, advance, bottom_right, logo_sprite, scene_in, sprite_count,
};
#[test]
fn hitting_a_corner_bursts_sparkles_at_that_corner() {
let mut scene = scene_in(SNUG);
advance(&mut scene, SNUG, 1);
let corner = bottom_right(SNUG);
assert!(scene.sprites().any(|sprite| sprite.position() == corner));
assert!(sprite_count(&scene) > 1);
}
#[test]
fn hitting_a_single_edge_recolours_but_does_not_burst() {
let mut scene = scene_in(EDGE_ONLY);
let before = logo_sprite(&scene).color();
advance(&mut scene, EDGE_ONLY, 1);
assert_ne!(logo_sprite(&scene).color(), before);
assert_eq!(sprite_count(&scene), 1);
}
#[test]
fn sparkles_expire_so_the_scene_reaches_a_steady_size() {
let mut scene = scene_in(SNUG);
advance(&mut scene, SNUG, 64);
let steady = sprite_count(&scene);
advance(&mut scene, SNUG, 512);
assert_eq!(sprite_count(&scene), steady);
}
#[test]
fn the_sparkle_buffer_holds_the_worst_case_without_dropping_any() {
let mut scene = scene_in(SNUG);
advance(&mut scene, SNUG, 256);
assert_eq!(sprite_count(&scene), 12 * 9 + 1);
}
#[test]
fn bounds_shrinking_below_the_logo_neither_recolours_nor_bursts() {
let mut scene = scene_in(ROOMY);
let before = logo_sprite(&scene).color();
advance(&mut scene, TINY, 64);
assert_eq!(logo_sprite(&scene).color(), before);
assert_eq!(sprite_count(&scene), 1);
}
#[test]
fn the_logo_never_leaves_the_bounds() {
let mut scene = scene_in(ROOMY);
for _ in 0..2048 {
advance(&mut scene, ROOMY, 1);
let logo = logo_sprite(&scene);
let (position, size) = (logo.position(), logo.dimensions());
assert!(position.x() >= 0.0 && position.y() >= 0.0);
assert!(position.x() + size.width() as f32 <= ROOMY.width() as f32);
assert!(position.y() + size.height() as f32 <= ROOMY.height() as f32);
}
}