From 15fdace32474e65dd2fcc63ee10a68a834199334 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 6 Aug 2026 20:11:22 +0200 Subject: [PATCH] init v.1.0.0 Co-authored-by: Copilot --- .gitea/workflows/ci.yml | 49 ++ .github/workflows/ci.yml | 49 ++ .gitignore | 3 + Cargo.lock | 566 ++++++++++++++++++++ Cargo.toml | 29 + LICENSE | 21 + Makefile | 51 ++ README.md | 28 + architecture.mmd | 93 ++++ crates/adapters/assets/Cargo.toml | 12 + crates/adapters/assets/src/lib.rs | 113 ++++ crates/adapters/assets/tests/common/mod.rs | 41 ++ crates/adapters/assets/tests/config_path.rs | 117 ++++ crates/adapters/assets/tests/loading.rs | 86 +++ crates/adapters/wayland/Cargo.toml | 16 + crates/adapters/wayland/src/display.rs | 219 ++++++++ crates/adapters/wayland/src/image.rs | 123 +++++ crates/adapters/wayland/src/lib.rs | 8 + crates/adapters/wayland/src/renderer.rs | 304 +++++++++++ crates/adapters/wayland/src/state.rs | 155 ++++++ crates/application/Cargo.toml | 11 + crates/application/src/errors.rs | 55 ++ crates/application/src/lib.rs | 9 + crates/application/src/ports.rs | 20 + crates/application/src/use_cases.rs | 37 ++ crates/bin/Cargo.toml | 16 + crates/bin/src/main.rs | 39 ++ crates/domain/Cargo.toml | 10 + crates/domain/src/errors.rs | 14 + crates/domain/src/lib.rs | 13 + crates/domain/src/logo.rs | 125 +++++ crates/domain/src/scene.rs | 73 +++ crates/domain/src/sparkle.rs | 60 +++ crates/domain/src/sprite.rs | 47 ++ crates/domain/src/value_objects.rs | 97 ++++ crates/domain/tests/common/mod.rs | 62 +++ crates/domain/tests/construction.rs | 37 ++ crates/domain/tests/motion.rs | 75 +++ 38 files changed, 2883 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 architecture.mmd create mode 100644 crates/adapters/assets/Cargo.toml create mode 100644 crates/adapters/assets/src/lib.rs create mode 100644 crates/adapters/assets/tests/common/mod.rs create mode 100644 crates/adapters/assets/tests/config_path.rs create mode 100644 crates/adapters/assets/tests/loading.rs create mode 100644 crates/adapters/wayland/Cargo.toml create mode 100644 crates/adapters/wayland/src/display.rs create mode 100644 crates/adapters/wayland/src/image.rs create mode 100644 crates/adapters/wayland/src/lib.rs create mode 100644 crates/adapters/wayland/src/renderer.rs create mode 100644 crates/adapters/wayland/src/state.rs create mode 100644 crates/application/Cargo.toml create mode 100644 crates/application/src/errors.rs create mode 100644 crates/application/src/lib.rs create mode 100644 crates/application/src/ports.rs create mode 100644 crates/application/src/use_cases.rs create mode 100644 crates/bin/Cargo.toml create mode 100644 crates/bin/src/main.rs create mode 100644 crates/domain/Cargo.toml create mode 100644 crates/domain/src/errors.rs create mode 100644 crates/domain/src/lib.rs create mode 100644 crates/domain/src/logo.rs create mode 100644 crates/domain/src/scene.rs create mode 100644 crates/domain/src/sparkle.rs create mode 100644 crates/domain/src/sprite.rs create mode 100644 crates/domain/src/value_objects.rs create mode 100644 crates/domain/tests/common/mod.rs create mode 100644 crates/domain/tests/construction.rs create mode 100644 crates/domain/tests/motion.rs 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); + } +}