commit 15fdace32474e65dd2fcc63ee10a68a834199334 Author: Gabriel Kaszewski Date: Thu Aug 6 20:11:22 2026 +0200 init v.1.0.0 Co-authored-by: Copilot diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..f5e0566 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f5e0566 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c2b11c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +*~ +/assets diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..75fb46d --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..15ba888 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31bafd2 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a91ebae --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f05689 --- /dev/null +++ b/README.md @@ -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 diff --git a/architecture.mmd b/architecture.mmd new file mode 100644 index 0000000..dfd6a86 --- /dev/null +++ b/architecture.mmd @@ -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
the only place that names Wayland"] + end + + subgraph adapters["adapters — std"] + direction LR + + subgraph wayland["wayland"] + display["WaylandDisplay
layer-shell surface, frame pacing, signals"] + renderer["SoftwareRenderer
double-buffered shm, damage tracking"] + logoimage["LogoImage
decode, scale, tint cache"] + state["WaylandState
protocol dispatch"] + end + + subgraph assets["assets"] + filesource["FileLogoSource
$DVD_LOGO, XDG / macOS / Windows"] + end + end + + subgraph app["application — no_std, no alloc"] + direction LR + + subgraph portsgroup["ports"] + displayport["DisplayOverlayPort
screen_dimensions, wait_for_frame"] + rendererport["RendererPort
render_frame"] + logoport["LogoSourcePort
load"] + end + + usecases["use_cases
run · blocking loop
step · one frame, for callback platforms"] + apperror["ApplicationError
heapless Message, 108 bytes"] + end + + subgraph dom["domain — no_std, no alloc"] + direction LR + + scene["ScreensaverScene
aggregate root
sole entry point: tick, sprites"] + logo["BouncingLogo
pub(crate)"] + sparkle["Sparkle
pub(crate)"] + sprite["Sprite + SpriteKind
what to draw, not how"] + vo["Value objects
Point2D · Vector2D
Dimensions · Color
self-validating, const fn"] + domerror["DomainError
InvalidDimensions"] + 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 diff --git a/crates/adapters/assets/Cargo.toml b/crates/adapters/assets/Cargo.toml new file mode 100644 index 0000000..31de906 --- /dev/null +++ b/crates/adapters/assets/Cargo.toml @@ -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 } diff --git a/crates/adapters/assets/src/lib.rs b/crates/adapters/assets/src/lib.rs new file mode 100644 index 0000000..3f4de7b --- /dev/null +++ b/crates/adapters/assets/src/lib.rs @@ -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) -> Option { + 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) -> Option { + path.filter(|path| !path.as_os_str().is_empty()) +} + +fn absolute(path: Option) -> Option { + path.filter(|path| path.is_absolute()) +} + +pub struct FileLogoSource { + bytes: Option>, +} + +impl FileLogoSource { + pub fn from_env() -> Result { + FileLogoSource::from_environment(Platform::CURRENT, &|key| { + env::var_os(key).map(PathBuf::from) + }) + } + + pub fn from_environment( + platform: Platform, + env: &impl Fn(&str) -> Option, + ) -> Result { + FileLogoSource::new(set(env(LOGO_ENV)), config_path(platform, env)) + } + + pub fn new( + explicit: Option, + configured: Option, + ) -> Result { + 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, ApplicationError> { + Ok(self.bytes.as_deref()) + } +} + +fn read(path: &Path) -> io::Result>> { + 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())) +} diff --git a/crates/adapters/assets/tests/common/mod.rs b/crates/adapters/assets/tests/common/mod.rs new file mode 100644 index 0000000..c8c52ad --- /dev/null +++ b/crates/adapters/assets/tests/common/mod.rs @@ -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 { + Some(PathBuf::from(value)) +} + +pub fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option + 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 +} diff --git a/crates/adapters/assets/tests/config_path.rs b/crates/adapters/assets/tests/config_path.rs new file mode 100644 index 0000000..3e02fa0 --- /dev/null +++ b/crates/adapters/assets/tests/config_path.rs @@ -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") + ); +} diff --git a/crates/adapters/assets/tests/loading.rs b/crates/adapters/assets/tests/loading.rs new file mode 100644 index 0000000..43aa6ba --- /dev/null +++ b/crates/adapters/assets/tests/loading.rs @@ -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)); +} diff --git a/crates/adapters/wayland/Cargo.toml b/crates/adapters/wayland/Cargo.toml new file mode 100644 index 0000000..8d098eb --- /dev/null +++ b/crates/adapters/wayland/Cargo.toml @@ -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 } diff --git a/crates/adapters/wayland/src/display.rs b/crates/adapters/wayland/src/display.rs new file mode 100644 index 0000000..6b875f6 --- /dev/null +++ b/crates/adapters/wayland/src/display.rs @@ -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, + qh: QueueHandle, + state: WaylandState, + layer_surface: LayerSurface, + signal_pipe: std::io::PipeReader, +} + +impl WaylandDisplay { + pub fn new() -> Result { + 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 { + &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 { + 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 { + 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) +} diff --git a/crates/adapters/wayland/src/image.rs b/crates/adapters/wayland/src/image.rs new file mode 100644 index 0000000..40572b3 --- /dev/null +++ b/crates/adapters/wayland/src/image.rs @@ -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, 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) +} diff --git a/crates/adapters/wayland/src/lib.rs b/crates/adapters/wayland/src/lib.rs new file mode 100644 index 0000000..59f9619 --- /dev/null +++ b/crates/adapters/wayland/src/lib.rs @@ -0,0 +1,8 @@ +mod display; +mod image; +mod renderer; +mod state; + +pub use display::WaylandDisplay; +pub use renderer::SoftwareRenderer; +pub use state::WaylandState; diff --git a/crates/adapters/wayland/src/renderer.rs b/crates/adapters/wayland/src/renderer.rs new file mode 100644 index 0000000..1132e2a --- /dev/null +++ b/crates/adapters/wayland/src/renderer.rs @@ -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, +} + +impl Frame { + fn new(pool: &mut SlotPool, spec: BufferSpec) -> Result { + 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, + pool: SlotPool, + logo: Option, + frames: [Frame; BUFFER_COUNT], + next: usize, + presented: Option, + committed: bool, +} + +impl SoftwareRenderer { + pub fn new( + display: &WaylandDisplay, + logo: &impl LogoSourcePort, + ) -> Result { + 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::, _>>()? + .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 { + 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, 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 = 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, drawn: Option) { + 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 { + 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::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) +} diff --git a/crates/adapters/wayland/src/state.rs b/crates/adapters/wayland/src/state.rs new file mode 100644 index 0000000..0b6baf1 --- /dev/null +++ b/crates/adapters/wayland/src/state.rs @@ -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, + _surface: &wl_surface::WlSurface, + _new_factor: i32, + ) { + } + + fn transform_changed( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _surface: &wl_surface::WlSurface, + _new_transform: wl_output::Transform, + ) { + } + + fn frame( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _surface: &wl_surface::WlSurface, + _time: u32, + ) { + self.redraw_requested = true; + } + + fn surface_enter( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _surface: &wl_surface::WlSurface, + _output: &wl_output::WlOutput, + ) { + } + + fn surface_leave( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _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, + _output: wl_output::WlOutput, + ) { + } + + fn update_output( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _output: wl_output::WlOutput, + ) { + } + + fn output_destroyed( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _output: wl_output::WlOutput, + ) { + } +} + +impl LayerShellHandler for WaylandState { + fn closed(&mut self, _conn: &Connection, _qh: &QueueHandle, _layer: &LayerSurface) { + self.exit = true; + } + + fn configure( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _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); diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..69c3de4 --- /dev/null +++ b/crates/application/Cargo.toml @@ -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 } diff --git a/crates/application/src/errors.rs b/crates/application/src/errors.rs new file mode 100644 index 0000000..e8e1cb2 --- /dev/null +++ b/crates/application/src/errors.rs @@ -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(()) + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..2650212 --- /dev/null +++ b/crates/application/src/lib.rs @@ -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}; diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs new file mode 100644 index 0000000..ecd86ac --- /dev/null +++ b/crates/application/src/ports.rs @@ -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; +} + +pub trait RendererPort { + fn render_frame( + &mut self, + scene: &ScreensaverScene, + screen_size: Dimensions, + ) -> Result<(), ApplicationError>; +} + +pub trait LogoSourcePort { + fn load(&self) -> Result, ApplicationError>; +} diff --git a/crates/application/src/use_cases.rs b/crates/application/src/use_cases.rs new file mode 100644 index 0000000..b0c554e --- /dev/null +++ b/crates/application/src/use_cases.rs @@ -0,0 +1,37 @@ +use domain::ScreensaverScene; + +use crate::{ + errors::ApplicationError, + ports::{DisplayOverlayPort, RendererPort}, +}; + +pub fn step( + 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( + 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(()) +} diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml new file mode 100644 index 0000000..08b0b99 --- /dev/null +++ b/crates/bin/Cargo.toml @@ -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 } diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs new file mode 100644 index 0000000..13d8ca7 --- /dev/null +++ b/crates/bin/src/main.rs @@ -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) +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..2e50f02 --- /dev/null +++ b/crates/domain/Cargo.toml @@ -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 } diff --git a/crates/domain/src/errors.rs b/crates/domain/src/errors.rs new file mode 100644 index 0000000..a850db4 --- /dev/null +++ b/crates/domain/src/errors.rs @@ -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, + }, +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..61c6df1 --- /dev/null +++ b/crates/domain/src/lib.rs @@ -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}; diff --git a/crates/domain/src/logo.rs b/crates/domain/src/logo.rs new file mode 100644 index 0000000..5e1900e --- /dev/null +++ b/crates/domain/src/logo.rs @@ -0,0 +1,125 @@ +use crate::{Color, Dimensions, Point2D, Sprite, SpriteKind, Vector2D}; + +struct Axis { + position: f32, + impact: Option, + 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 { + 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) + } +} diff --git a/crates/domain/src/scene.rs b/crates/domain/src/scene.rs new file mode 100644 index 0000000..e34c87f --- /dev/null +++ b/crates/domain/src/scene.rs @@ -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; MAX_SPARKLES], +} + +impl ScreensaverScene { + pub fn new( + logo: Dimensions, + speed: Vector2D, + color: Color, + bounds: Dimensions, + ) -> Result { + 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 + '_ { + core::iter::once(self.logo.sprite()) + .chain(self.sparkles.iter().flatten().map(Sparkle::sprite)) + } + + fn spawn(&mut self, burst: impl Iterator) { + 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, + } + } + } +} diff --git a/crates/domain/src/sparkle.rs b/crates/domain/src/sparkle.rs new file mode 100644 index 0000000..cdb65f4 --- /dev/null +++ b/crates/domain/src/sparkle.rs @@ -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 { + (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, + ) + } +} diff --git a/crates/domain/src/sprite.rs b/crates/domain/src/sprite.rs new file mode 100644 index 0000000..f0df80b --- /dev/null +++ b/crates/domain/src/sprite.rs @@ -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 + } +} diff --git a/crates/domain/src/value_objects.rs b/crates/domain/src/value_objects.rs new file mode 100644 index 0000000..8fa9a88 --- /dev/null +++ b/crates/domain/src/value_objects.rs @@ -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 { + 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::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 { + 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 { + 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, +} diff --git a/crates/domain/tests/common/mod.rs b/crates/domain/tests/common/mod.rs new file mode 100644 index 0000000..6537ea5 --- /dev/null +++ b/crates/domain/tests/common/mod.rs @@ -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::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) +} diff --git a/crates/domain/tests/construction.rs b/crates/domain/tests/construction.rs new file mode 100644 index 0000000..a493cdb --- /dev/null +++ b/crates/domain/tests/construction.rs @@ -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); +} diff --git a/crates/domain/tests/motion.rs b/crates/domain/tests/motion.rs new file mode 100644 index 0000000..aeff8d0 --- /dev/null +++ b/crates/domain/tests/motion.rs @@ -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); + } +}