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