Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
12
crates/adapters/assets/Cargo.toml
Normal file
12
crates/adapters/assets/Cargo.toml
Normal 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 }
|
||||
113
crates/adapters/assets/src/lib.rs
Normal file
113
crates/adapters/assets/src/lib.rs
Normal 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()))
|
||||
}
|
||||
41
crates/adapters/assets/tests/common/mod.rs
Normal file
41
crates/adapters/assets/tests/common/mod.rs
Normal 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
|
||||
}
|
||||
117
crates/adapters/assets/tests/config_path.rs
Normal file
117
crates/adapters/assets/tests/config_path.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
86
crates/adapters/assets/tests/loading.rs
Normal file
86
crates/adapters/assets/tests/loading.rs
Normal 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));
|
||||
}
|
||||
16
crates/adapters/wayland/Cargo.toml
Normal file
16
crates/adapters/wayland/Cargo.toml
Normal 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 }
|
||||
219
crates/adapters/wayland/src/display.rs
Normal file
219
crates/adapters/wayland/src/display.rs
Normal 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)
|
||||
}
|
||||
123
crates/adapters/wayland/src/image.rs
Normal file
123
crates/adapters/wayland/src/image.rs
Normal 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)
|
||||
}
|
||||
8
crates/adapters/wayland/src/lib.rs
Normal file
8
crates/adapters/wayland/src/lib.rs
Normal 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;
|
||||
304
crates/adapters/wayland/src/renderer.rs
Normal file
304
crates/adapters/wayland/src/renderer.rs
Normal 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)
|
||||
}
|
||||
155
crates/adapters/wayland/src/state.rs
Normal file
155
crates/adapters/wayland/src/state.rs
Normal 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);
|
||||
Reference in New Issue
Block a user