Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
49
.gitea/workflows/ci.yml
Normal file
49
.gitea/workflows/ci.yml
Normal 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
49
.github/workflows/ci.yml
vendored
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
*~
|
||||||
|
/assets
|
||||||
566
Cargo.lock
generated
Normal file
566
Cargo.lock
generated
Normal 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
29
Cargo.toml
Normal 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
21
LICENSE
Normal 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
51
Makefile
Normal 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
28
README.md
Normal 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
93
architecture.mmd
Normal 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
|
||||||
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);
|
||||||
11
crates/application/Cargo.toml
Normal file
11
crates/application/Cargo.toml
Normal 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 }
|
||||||
55
crates/application/src/errors.rs
Normal file
55
crates/application/src/errors.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/application/src/lib.rs
Normal file
9
crates/application/src/lib.rs
Normal 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};
|
||||||
20
crates/application/src/ports.rs
Normal file
20
crates/application/src/ports.rs
Normal 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>;
|
||||||
|
}
|
||||||
37
crates/application/src/use_cases.rs
Normal file
37
crates/application/src/use_cases.rs
Normal 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
16
crates/bin/Cargo.toml
Normal 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
39
crates/bin/src/main.rs
Normal 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
10
crates/domain/Cargo.toml
Normal 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 }
|
||||||
14
crates/domain/src/errors.rs
Normal file
14
crates/domain/src/errors.rs
Normal 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
13
crates/domain/src/lib.rs
Normal 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
125
crates/domain/src/logo.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
crates/domain/src/scene.rs
Normal file
73
crates/domain/src/scene.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
crates/domain/src/sparkle.rs
Normal file
60
crates/domain/src/sparkle.rs
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
crates/domain/src/sprite.rs
Normal file
47
crates/domain/src/sprite.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
97
crates/domain/src/value_objects.rs
Normal file
97
crates/domain/src/value_objects.rs
Normal 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,
|
||||||
|
}
|
||||||
62
crates/domain/tests/common/mod.rs
Normal file
62
crates/domain/tests/common/mod.rs
Normal 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)
|
||||||
|
}
|
||||||
37
crates/domain/tests/construction.rs
Normal file
37
crates/domain/tests/construction.rs
Normal 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);
|
||||||
|
}
|
||||||
75
crates/domain/tests/motion.rs
Normal file
75
crates/domain/tests/motion.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user