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

49
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
ci:
name: Check / Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: x86_64-unknown-none,riscv32imc-unknown-none-elf,wasm32-unknown-unknown,x86_64-pc-windows-gnu,aarch64-apple-darwin
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: test
run: cargo test --workspace
- name: no_std guard
run: make check-no-std
- name: portability guard
run: make check-portable

49
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
ci:
name: Check / Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: x86_64-unknown-none,riscv32imc-unknown-none-elf,wasm32-unknown-unknown,x86_64-pc-windows-gnu,aarch64-apple-darwin
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: fmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: test
run: cargo test --workspace
- name: no_std guard
run: make check-no-std
- name: portability guard
run: make check-portable

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
*~
/assets

566
Cargo.lock generated Normal file
View File

@@ -0,0 +1,566 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "application"
version = "1.0.0"
dependencies = [
"domain",
"heapless",
"thiserror",
]
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "assets"
version = "1.0.0"
dependencies = [
"application",
]
[[package]]
name = "bin"
version = "1.0.0"
dependencies = [
"application",
"assets",
"domain",
"wayland",
]
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "domain"
version = "1.0.0"
dependencies = [
"libm",
"thiserror",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]]
name = "heapless"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "smithay-client-toolkit"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dc9ee14b0fdcb535f9556141bacac070c994a977d2240ee455d7438a617f00"
dependencies = [
"bitflags",
"cursor-icon",
"log",
"memmap2",
"rustix",
"thiserror",
"wayland-client",
"wayland-csd-frame",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-experimental",
"wayland-protocols-misc",
"wayland-protocols-wlr",
"wayland-scanner",
"xkeysym",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wayland"
version = "1.0.0"
dependencies = [
"application",
"domain",
"rustix",
"signal-hook",
"smithay-client-toolkit",
"tiny-skia",
"wayland-client",
]
[[package]]
name = "wayland-backend"
version = "0.3.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-csd-frame"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
dependencies = [
"bitflags",
"cursor-icon",
"wayland-backend",
]
[[package]]
name = "wayland-cursor"
version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
dependencies = [
"rustix",
"wayland-client",
"xcursor",
]
[[package]]
name = "wayland-protocols"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-experimental"
version = "20251230.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c6c3e7178e553d093d46999032bc79aafea9dedd71dd702c8ace7bbc25cf13"
dependencies = [
"bitflags",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-misc"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
dependencies = [
"bitflags",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"pkg-config",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "xcursor"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"

29
Cargo.toml Normal file
View File

@@ -0,0 +1,29 @@
[workspace]
members = [
"crates/adapters/assets",
"crates/adapters/wayland",
"crates/application",
"crates/bin",
"crates/domain",
]
resolver = "2"
[workspace.package]
version = "1.0.0"
edition = "2024"
license = "MIT"
authors = ["Gabriel Kaszewski"]
[workspace.dependencies]
domain = { path = "crates/domain" }
application = { path = "crates/application" }
wayland = { path = "crates/adapters/wayland" }
assets = { path = "crates/adapters/assets" }
thiserror = { version = "2.0", default-features = false }
[profile.release]
opt-level = "z"
lto = true
strip = true
codegen-units = 1
panic = "abort"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabriel Kaszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

51
Makefile Normal file
View File

@@ -0,0 +1,51 @@
.PHONY: build dev check check-all check-no-std check-portable targets test fmt run install clean
RELEASE_BIN := target/release/dvd-thing
INSTALL_DIR := $(HOME)/.local/bin
NO_STD_TARGETS := x86_64-unknown-none riscv32imc-unknown-none-elf
PORTABLE_TARGETS := wasm32-unknown-unknown x86_64-pc-windows-gnu aarch64-apple-darwin
build:
cargo build --release
dev:
RUST_LOG=debug cargo run
check:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
check-all: check check-no-std check-portable
check-no-std:
@for target in $(NO_STD_TARGETS); do \
echo " domain + application -> $$target"; \
cargo check --quiet -p domain -p application --target $$target || exit 1; \
done
check-portable:
@for target in $(PORTABLE_TARGETS); do \
echo " domain + application + assets -> $$target"; \
cargo check --quiet -p domain -p application -p assets --target $$target || exit 1; \
done
targets:
rustup target add $(NO_STD_TARGETS) $(PORTABLE_TARGETS)
test:
cargo test --workspace
fmt:
cargo fmt --all
run:
cargo run --release
install: build
install -Dm755 $(RELEASE_BIN) $(INSTALL_DIR)/dvd-thing
clean:
cargo clean

28
README.md Normal file
View File

@@ -0,0 +1,28 @@
# dvd-thing
I have no idea. Was bored. created this.
runs on wayland. needs `wlr-layer-shell`, so: KDE (tested), sway, hyprland.
does **not** work on gnome, mutter doesn't implement `wlr-layer-shell` and never will, so it just
errors out on startup.
what it does?
it bounces a dvd logo around the screen, that's it.
## logo
no logo ships with this repo, the dvd logo is a trademark, not mine to hand out under MIT.
drop a png at:
~/.config/dvd-thing/logo.png
or point at one:
DVD_LOGO=/path/to/logo.png dvd-thing
white-on-transparent works best: the alpha channel is the shape, the rgb is thrown away and
replaced by the bouncing colour cycle. with no logo configured you just get a rectangle.
you can always put whatever image you want in there.
License: [MIT](./LICENSE) © 2026 Gabriel Kaszewski

93
architecture.mmd Normal file
View File

@@ -0,0 +1,93 @@
%% dvd-thing — ports and adapters.
%% Every dependency arrow points inward. domain knows nothing; application knows
%% only domain; adapters know both but are known by neither.
flowchart TB
subgraph root["bin — composition root"]
main["main.rs<br/><i>the only place that names Wayland</i>"]
end
subgraph adapters["adapters — std"]
direction LR
subgraph wayland["wayland"]
display["WaylandDisplay<br/><i>layer-shell surface, frame pacing, signals</i>"]
renderer["SoftwareRenderer<br/><i>double-buffered shm, damage tracking</i>"]
logoimage["LogoImage<br/><i>decode, scale, tint cache</i>"]
state["WaylandState<br/><i>protocol dispatch</i>"]
end
subgraph assets["assets"]
filesource["FileLogoSource<br/><i>$DVD_LOGO, XDG / macOS / Windows</i>"]
end
end
subgraph app["application — no_std, no alloc"]
direction LR
subgraph portsgroup["ports"]
displayport["DisplayOverlayPort<br/><i>screen_dimensions, wait_for_frame</i>"]
rendererport["RendererPort<br/><i>render_frame</i>"]
logoport["LogoSourcePort<br/><i>load</i>"]
end
usecases["use_cases<br/><b>run</b> · blocking loop<br/><b>step</b> · one frame, for callback platforms"]
apperror["ApplicationError<br/><i>heapless Message, 108 bytes</i>"]
end
subgraph dom["domain — no_std, no alloc"]
direction LR
scene["ScreensaverScene<br/><b>aggregate root</b><br/><i>sole entry point: tick, sprites</i>"]
logo["BouncingLogo<br/><i>pub(crate)</i>"]
sparkle["Sparkle<br/><i>pub(crate)</i>"]
sprite["Sprite + SpriteKind<br/><i>what to draw, not how</i>"]
vo["Value objects<br/>Point2D · Vector2D<br/>Dimensions · Color<br/><i>self-validating, const fn</i>"]
domerror["DomainError<br/><i>InvalidDimensions</i>"]
end
main --> usecases
main --> scene
main -.->|constructs| display
main -.->|constructs| renderer
main -.->|constructs| filesource
usecases --> displayport
usecases --> rendererport
usecases --> scene
display -.->|implements| displayport
renderer -.->|implements| rendererport
filesource -.->|implements| logoport
renderer -->|"sprites()"| scene
renderer --> logoimage
logoimage -->|"load()"| logoport
renderer --> display
display --> state
scene -->|owns| logo
scene -->|"owns [Option Sparkle; 108]"| sparkle
logo --> sprite
sparkle --> sprite
sprite --> vo
scene --> vo
scene -.->|"new() rejects"| domerror
apperror -.->|wraps| domerror
classDef domainStyle fill:#1f4e3d,stroke:#41b883,color:#e8f5ee
classDef appStyle fill:#1e3a5f,stroke:#4a9eff,color:#e6f0ff
classDef adapterStyle fill:#4a3410,stroke:#d99a2b,color:#fdf3e0
classDef rootStyle fill:#3d2645,stroke:#b06fc9,color:#f6ecfa
style root fill:#88888810,stroke:#9a9a9a
style adapters fill:#88888810,stroke:#9a9a9a
style wayland fill:#88888810,stroke:#9a9a9a
style assets fill:#88888810,stroke:#9a9a9a
style app fill:#88888810,stroke:#9a9a9a
style portsgroup fill:#88888810,stroke:#9a9a9a
style dom fill:#88888810,stroke:#9a9a9a
class scene,logo,sparkle,sprite,vo,domerror domainStyle
class displayport,rendererport,logoport,usecases,apperror appStyle
class display,renderer,logoimage,state,filesource adapterStyle
class main rootStyle

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);
}
}