From 163037ca9105310317085981bbeb635c6273828f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Dec 2025 17:23:10 +0000 Subject: [PATCH] feat: Add screenshot export with Ctrl/Cmd+P Implements screenshot capture functionality that works across all BERT deployment contexts (web browser and Tauri desktop). Key implementation details: - Uses web APIs (Blob + anchor download) instead of native file I/O - Works in WASM since BERT's Tauri desktop build uses a webview - Timestamps via js_sys::Date for WASM compatibility - PNG encoding via image crate - Integrates with existing SaveSuccessEvent for toast notifications This implementation builds on the approach proposed in PR #6 by @NehharShah, adapted for BERT's Tauri+WASM architecture where target_arch is always wasm32 even on desktop. Closes #1 --- Cargo.lock | 428 ++++++++++++++++++++++++++++- Cargo.toml | 13 +- src/bevy_app/mod.rs | 5 + src/bevy_app/systems/mod.rs | 2 + src/bevy_app/systems/screenshot.rs | 178 ++++++++++++ 5 files changed, 613 insertions(+), 13 deletions(-) create mode 100644 src/bevy_app/systems/screenshot.rs diff --git a/Cargo.lock b/Cargo.lock index 6c047d2..bca779a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,7 +114,7 @@ dependencies = [ "getrandom 0.2.15", "once_cell", "version_check", - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -126,6 +126,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -243,6 +252,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arboard" version = "3.4.1" @@ -261,6 +276,17 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -557,6 +583,29 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -586,7 +635,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bert" -version = "0.2.2" +version = "0.2.3" dependencies = [ "bevy", "bevy-inspector-egui", @@ -596,6 +645,7 @@ dependencies = [ "console_error_panic_hook", "enum-iterator", "gloo-file", + "image", "js-sys", "leptos", "leptos-bevy-canvas", @@ -619,7 +669,7 @@ dependencies = [ [[package]] name = "bert-tauri" -version = "0.2.2" +version = "0.2.3" dependencies = [ "leptos", "serde", @@ -1212,7 +1262,7 @@ dependencies = [ "bevy_reflect", "derive_more 1.0.0", "glam", - "itertools", + "itertools 0.13.0", "rand 0.8.5", "rand_distr", "serde", @@ -1681,7 +1731,7 @@ dependencies = [ "bitflags 2.8.0", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -1722,6 +1772,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -1737,6 +1793,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + [[package]] name = "bitvec" version = "1.0.1" @@ -1853,6 +1915,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + [[package]] name = "bumpalo" version = "3.17.0" @@ -2038,7 +2106,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -2151,6 +2219,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "186dce98367766de751c42c4f03970fc60fc012296e706ccbb9d5df9b6c1e271" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -2177,7 +2251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" dependencies = [ "convert_case 0.6.0", - "nom", + "nom 7.1.3", "pathdiff", "serde", "toml 0.8.20", @@ -3000,6 +3074,26 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66f6ddac3e6ac6fd4c3d48bb8b1943472f8da0f43a4303bcd8a18aa594401c80" +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -3068,6 +3162,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -3493,6 +3602,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "gif" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc37f9a2bfe731e69f1e08d29d91d30604b9ce24bcb2880a961e82d89c6ed89" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gilrs" version = "0.11.0" @@ -3901,6 +4020,17 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy 0.8.27", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -4276,11 +4406,37 @@ checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "num-traits", "png", + "qoi", + "ravif", + "rayon", + "rgb", "tiff", + "zune-core", + "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "immutable-chunkmap" version = "2.0.6" @@ -4347,6 +4503,17 @@ dependencies = [ "libc", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "interpolator" version = "0.5.0" @@ -4388,6 +4555,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -4560,6 +4736,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "leptos" version = "0.7.7" @@ -4689,7 +4871,7 @@ dependencies = [ "cfg-if", "convert_case 0.6.0", "html-escape", - "itertools", + "itertools 0.13.0", "leptos_hot_reload", "prettyplease", "proc-macro-error2", @@ -4778,6 +4960,16 @@ version = "0.2.170" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.7.4" @@ -4865,6 +5057,15 @@ version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lyon_algorithms" version = "1.0.5" @@ -4994,6 +5195,16 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.7.4" @@ -5229,12 +5440,27 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nonmax" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "ntapi" version = "0.4.1" @@ -5254,6 +5480,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -5271,6 +5507,26 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -6149,7 +6405,7 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -6292,6 +6548,19 @@ name = "profiling" version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.98", +] [[package]] name = "ptr_meta" @@ -6313,6 +6582,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.32.0" @@ -6477,6 +6761,56 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60fcc7d6849342eff22c4350c8b9a989ee8ceabc4b481253e8946b9fe83d684" +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.12.1", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand 0.8.5", + "rand_chacha 0.3.1", + "simd_helpers", + "system-deps", + "thiserror 1.0.69", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -6531,7 +6865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74c3d2a20d8edd8ac6628718209f743da86349d7f10a4458304666c2ddfc082e" dependencies = [ "guardian", - "itertools", + "itertools 0.13.0", "or_poisoned", "paste", "reactive_graph", @@ -6721,6 +7055,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + [[package]] name = "rkyv" version = "0.7.45" @@ -7281,6 +7621,15 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -7631,7 +7980,7 @@ dependencies = [ "futures", "html-escape", "indexmap 2.7.1", - "itertools", + "itertools 0.13.0", "js-sys", "linear-map", "next_tuple", @@ -8752,6 +9101,17 @@ dependencies = [ "serde", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -10252,7 +10612,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive 0.8.27", ] [[package]] @@ -10266,6 +10635,17 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "zerofrom" version = "0.1.5" @@ -10309,6 +10689,30 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.4.0" diff --git a/Cargo.toml b/Cargo.toml index 0f0c03f..820f8ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ bevy_prototype_lyon = "0.13.0" console_error_panic_hook = "0.1.7" enum-iterator = "2.1.0" gloo-file = "0.3" +image = "0.25" js-sys = "0.3" leptos = { version = "0.7", features = ["csr"] } leptos-bevy-canvas = { git = "https://github.com/Synphonyte/leptos-bevy-canvas.git" } @@ -36,7 +37,17 @@ tauri-sys = { git = "https://github.com/JonasKruckenberg/tauri-sys", branch = "v ] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["console"] } +web-sys = { version = "0.3", features = [ + "console", + "Window", + "Document", + "Element", + "HtmlElement", + "HtmlAnchorElement", + "Blob", + "BlobPropertyBag", + "Url", +] } uuid = { version = "1.12.1", features = ["v4"] } [workspace] diff --git a/src/bevy_app/mod.rs b/src/bevy_app/mod.rs index f036ed5..1b21635 100644 --- a/src/bevy_app/mod.rs +++ b/src/bevy_app/mod.rs @@ -288,6 +288,11 @@ pub fn init_bevy_app( .in_set(AllSet), ) .add_systems(Update, (react_to_trigger_event, toggle_theme_system)) // , handle_save_success_events)) + // Screenshot capture - Ctrl/Cmd+P triggers browser download + .add_systems( + Update, + take_screenshot.run_if(input_pressed(MODIFIER).and(input_just_pressed(KeyCode::KeyP))), + ) .add_systems( PostUpdate, ( diff --git a/src/bevy_app/systems/mod.rs b/src/bevy_app/systems/mod.rs index 0851834..a6c8a39 100644 --- a/src/bevy_app/systems/mod.rs +++ b/src/bevy_app/systems/mod.rs @@ -111,6 +111,7 @@ mod camera; mod removal; +mod screenshot; mod setup; mod spatial_sync; mod subsystem; @@ -120,6 +121,7 @@ mod ui; use bevy::ecs::system::{RunSystemOnce, SystemState}; pub use camera::*; pub use removal::*; +pub use screenshot::*; pub use setup::*; pub use spatial_sync::*; pub use subsystem::*; diff --git a/src/bevy_app/systems/screenshot.rs b/src/bevy_app/systems/screenshot.rs new file mode 100644 index 0000000..4a4dbf4 --- /dev/null +++ b/src/bevy_app/systems/screenshot.rs @@ -0,0 +1,178 @@ +//! Screenshot capture system for BERT diagrams. +//! +//! Provides cross-platform screenshot functionality using browser download APIs. +//! Works in both web browser and Tauri desktop contexts since BERT compiles to WASM +//! in both cases (Tauri wraps a WASM webview). +//! +//! ## Usage +//! +//! Press `Ctrl/Cmd+P` to capture a screenshot. The image will be downloaded as a +//! timestamped PNG file (e.g., `bert_screenshot_2025-01-15_14-30-00.png`). +//! +//! ## Architecture Note +//! +//! BERT's desktop build uses Tauri which wraps a WASM webview, meaning +//! `target_arch` is always `wasm32` even on desktop. This implementation uses +//! web APIs (Blob, anchor element download) rather than native file I/O to ensure +//! compatibility across all deployment contexts. + +use bevy::{ + prelude::*, + render::{ + render_asset::RenderAssetUsages, + render_resource::Extent3d, + view::screenshot::{Screenshot, ScreenshotCaptured}, + }, +}; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use web_sys::{Blob, BlobPropertyBag, HtmlAnchorElement, Url}; + +use crate::events::SaveSuccessEvent; + +/// Component to store the intended filename for a screenshot. +#[derive(Component)] +struct ScreenshotFilename(String); + +/// Initiates a screenshot capture with a timestamped filename. +/// +/// This system spawns a screenshot request using Bevy's `Screenshot` component +/// and attaches an observer to handle the captured image data. +pub fn take_screenshot(mut commands: Commands) { + // Use JS Date API for WASM-compatible timestamps + let js_date = js_sys::Date::new_0(); + let year = js_date.get_full_year() as u32; + let month = js_date.get_month() as u32 + 1; // JS months are 0-indexed + let day = js_date.get_date() as u32; + let hour = js_date.get_hours() as u32; + let minute = js_date.get_minutes() as u32; + let second = js_date.get_seconds() as u32; + + let timestamp = format!( + "{:04}-{:02}-{:02}_{:02}-{:02}-{:02}", + year, month, day, hour, minute, second + ); + + let filename = format!("bert_screenshot_{}.png", timestamp); + info!("Initiating screenshot capture: {}", filename); + + commands + .spawn(( + Screenshot::primary_window(), + ScreenshotFilename(filename.clone()), + )) + .observe(screenshot_download_handler); +} + +/// Observer handler that processes captured screenshot data and triggers a browser download. +/// +/// This function: +/// 1. Extracts raw RGBA image data from Bevy's screenshot event +/// 2. Converts it to PNG format using the `image` crate +/// 3. Triggers a browser download via Blob and anchor element +fn screenshot_download_handler( + trigger: Trigger, + filename_query: Query<&ScreenshotFilename>, + mut save_events: EventWriter, +) { + let entity = trigger.entity(); + + let Ok(screenshot_filename) = filename_query.get(entity) else { + error!("Screenshot entity missing ScreenshotFilename component"); + return; + }; + + let filename = &screenshot_filename.0; + let screenshot_data = &trigger.event().0; + + // Create Bevy Image from raw screenshot data + let image = Image::new( + Extent3d { + width: screenshot_data.texture_descriptor.size.width, + height: screenshot_data.texture_descriptor.size.height, + depth_or_array_layers: 1, + }, + bevy::render::render_resource::TextureDimension::D2, + screenshot_data.data.clone(), + screenshot_data.texture_descriptor.format, + RenderAssetUsages::default(), + ); + + // Convert to PNG bytes + let png_bytes = match image.clone().try_into_dynamic() { + Ok(dynamic_image) => { + let mut bytes: Vec = Vec::new(); + if let Err(e) = dynamic_image.write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) { + error!("Failed to encode screenshot as PNG: {}", e); + return; + } + bytes + } + Err(e) => { + error!("Failed to convert screenshot to dynamic image: {}", e); + return; + } + }; + + // Trigger browser download + if let Err(e) = trigger_browser_download(&png_bytes, filename) { + error!("Failed to trigger browser download: {:?}", e); + return; + } + + info!("Screenshot saved to Downloads: {}", filename); + + save_events.send(SaveSuccessEvent { + file_path: Some(filename.clone()), + message: format!("Screenshot saved to Downloads: {}", filename), + }); +} + +/// Triggers a browser download by creating a Blob URL and clicking a hidden anchor element. +/// +/// This approach works in both web browsers and Tauri's webview context. +fn trigger_browser_download(png_bytes: &[u8], filename: &str) -> Result<(), JsValue> { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; + let document = window + .document() + .ok_or_else(|| JsValue::from_str("No document available"))?; + + // Create Uint8Array from PNG bytes + let uint8_array = js_sys::Uint8Array::from(png_bytes); + let array = js_sys::Array::new(); + array.push(&uint8_array); + + // Create Blob with PNG MIME type + let blob_options = BlobPropertyBag::new(); + blob_options.set_type("image/png"); + let blob = Blob::new_with_u8_array_sequence_and_options(&array, &blob_options)?; + + // Create object URL for the blob + let url = Url::create_object_url_with_blob(&blob)?; + + // Create hidden anchor element and trigger download + let anchor = document + .create_element("a")? + .dyn_into::()?; + + anchor.set_href(&url); + anchor.set_download(filename); + anchor.style().set_property("display", "none")?; + + // Append to body, click, and cleanup + let body = document + .body() + .ok_or_else(|| JsValue::from_str("No body element"))?; + + body.append_child(&anchor)?; + anchor.click(); + body.remove_child(&anchor)?; + + // Revoke the object URL to free memory + Url::revoke_object_url(&url)?; + + Ok(()) +}