diff --git a/README.md b/README.md index 4fbe36f..0952cf6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ pinned `avalonia-src` producer submodule at upstream Avalonia `12.1.3` (commit | `apps/pdf-viewer/` | A PDF Oxide-powered sample viewer with rendered page navigation and extracted text | | `apps/hash-calculator/` | A streaming checksum utility (MD5, SHA-1, SHA-256, SHA-512, BLAKE3) with compare and copy | | `apps/archive-tool/` | A dual-pane archive manager (zip/tar/7z/cab and compressed tars) | +| `apps/jiffshot/` | A screen capture tool and image/video editor, all Rust on the code-first API (pixel surfaces, a Rust-built tray icon) | | `templates/` | Copy-out standalone app skeletons using the published bindings and prebuilt host | | `tests/` | Host, IR, and generator test suites | | `samples/` | `RustViewModelSample.Managed` - the sample presentation project the host consumes | diff --git a/apps/jiffshot/.cargo/config.toml b/apps/jiffshot/.cargo/config.toml new file mode 100644 index 0000000..80aeda1 --- /dev/null +++ b/apps/jiffshot/.cargo/config.toml @@ -0,0 +1,12 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] + +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] + +# Jiffshot uses Rustolonia's release and its prebuilt host. The repository's +# own .cargo/config.toml turns host downloads off for everything else in this +# checkout; this turns them back on here (outside the repository it changes +# nothing). +[env] +RUSTOLONIA_NO_DOWNLOAD = "0" diff --git a/apps/jiffshot/.gitignore b/apps/jiffshot/.gitignore new file mode 100644 index 0000000..4b67358 --- /dev/null +++ b/apps/jiffshot/.gitignore @@ -0,0 +1,2 @@ +# Rust +/target/ diff --git a/apps/jiffshot/Cargo.lock b/apps/jiffshot/Cargo.lock new file mode 100644 index 0000000..a8426b3 --- /dev/null +++ b/apps/jiffshot/Cargo.lock @@ -0,0 +1,1422 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[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 = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "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 = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiffshot" +version = "0.1.0" +dependencies = [ + "image", + "jiffshot-capture", + "jiffshot-doc", + "jiffshot-workflow", + "parking_lot", + "rustolonia", + "rustolonia-sys", + "serde", + "serde_json", + "tiny-skia", + "windows-sys 0.59.0", +] + +[[package]] +name = "jiffshot-capture" +version = "0.1.0" +dependencies = [ + "png 0.17.16", + "serde", + "serde_json", + "windows", + "windows-sys 0.59.0", +] + +[[package]] +name = "jiffshot-doc" +version = "0.1.0" +dependencies = [ + "ab_glyph", + "image", + "serde", + "serde_json", + "tiny-skia", + "zip", +] + +[[package]] +name = "jiffshot-workflow" +version = "0.1.0" +dependencies = [ + "jiffshot-capture", + "jiffshot-doc", + "serde", + "serde_json", + "tiny-skia", + "windows", + "windows-sys 0.59.0", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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 = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[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 = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustolonia" +version = "12.1.2" +source = "git+https://github.com/Devolutions/rustolonia?tag=v12.1.2#73732f9e78d9a40196ab12f7beac7654fabea097" +dependencies = [ + "rustolonia-sys", +] + +[[package]] +name = "rustolonia-sys" +version = "12.1.2" +source = "git+https://github.com/Devolutions/rustolonia?tag=v12.1.2#73732f9e78d9a40196ab12f7beac7654fabea097" +dependencies = [ + "dirs", + "flate2", + "libloading", + "sha2", + "tar", + "ureq", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/apps/jiffshot/Cargo.toml b/apps/jiffshot/Cargo.toml new file mode 100644 index 0000000..eb0affb --- /dev/null +++ b/apps/jiffshot/Cargo.toml @@ -0,0 +1,67 @@ +[workspace] +resolver = "2" +members = ["app", "crates/jiffshot-capture", "crates/jiffshot-doc", "crates/jiffshot-workflow"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +publish = false + +[workspace.dependencies] +# Rustolonia's release: its prebuilt host is downloaded and checked against +# the checksums pinned at the tag. +rustolonia = { git = "https://github.com/Devolutions/rustolonia", tag = "v12.1.2" } +rustolonia-sys = { git = "https://github.com/Devolutions/rustolonia", tag = "v12.1.2", default-features = false } +ab_glyph = "0.2" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "gif", "webp"] } +zip = { version = "2", default-features = false, features = ["deflate"] } +png = "0.17" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tiny-skia = "0.11" +windows = { version = "0.62", features = [ + "Foundation", + "Foundation_Collections", + "Globalization", + "Graphics_Imaging", + "Media_Ocr", + "Storage_Streams", + "Win32_Media_Audio", + "Win32_Media_KernelStreaming", + "Win32_Media_MediaFoundation", + "Win32_Media_Multimedia", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_Ole", + "Win32_System_Variant", + "Win32_System_WinRT", + "Win32_UI_Accessibility", +] } +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_Media", + "Win32_Graphics_Dwm", + "Win32_Graphics_Gdi", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_Storage_Xps", + "Win32_System_Com", + "Win32_System_Console", + "Win32_System_DataExchange", + "Win32_System_LibraryLoader", + "Win32_System_IO", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_RemoteDesktop", + "Win32_System_Registry", + "Win32_System_SystemInformation", + "Win32_System_Threading", + "Win32_UI_Controls_Dialogs", + "Win32_UI_HiDpi", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } + +[profile.release] +debug = "line-tables-only" diff --git a/apps/jiffshot/README.md b/apps/jiffshot/README.md new file mode 100644 index 0000000..4365d0f --- /dev/null +++ b/apps/jiffshot/README.md @@ -0,0 +1,102 @@ +# Jiffshot + +A screen capture tool and image editor for Windows, written entirely in Rust +with its UI on Rustolonia's code-first API (no AXAML, no C#). It runs as one +program, `jiffshot.exe`: the tray icon and capture side on their own thread, +the editor and every other window on Avalonia. + +## Features + +**Capture** +- Region, window (the mouse wheel walks up and down child elements), screen + and all screens, from a frozen overlay with a magnifier, size labels and + aspect locks (Shift square, Ctrl 16:9, Ctrl+Shift 4:3). +- Scrolling (vertical, horizontal or both), panoramic, freehand, multiple + areas, fixed size, open menus, Grab Text (Windows OCR) and Step Capture (a + guide of numbered screenshots, one per click). +- Time delay, time-lapse and a daily schedule; presets with their own + hotkeys; the Capture window (All-in-One, Image and Video tabs) and a small + Capture Widget at the top of the screen. +- After a capture: capture effects, the clipboard, the editor, and share + destinations (file, email, Office apps, any program, FTP, print). + +**Recording** +- An area, a window or a screen, with system sound, the microphone or both, + the webcam in a corner, pointer highlight and click rings, and Screen Draw. +- A toolbar under the area: stop, pause, restart, minimize, cancel, and the + pointer, webcam and sound switches, which change the recording as it runs. +- In the editor: trim, cut, frame stepping, GIF export and videos made from + images. + +**Editor** +- Arrow, line, shape, text, callout, step, pen, highlight, blur, magnify, + spotlight, stamps, fill, crop, cut out, selection, magic wand, eraser, + eyedropper, Smart Move and Text Replace, with quick styles. +- Effects (borders, shadows, edges, perspective, page curl and more), + templates with drop zones, and combined images. +- A library of captures with search, filters, tags and pins; export to PNG, + JPEG, PDF, BMP, GIF and WebP; editable `.jsd` documents. + +One Preferences dialog (Capture, Editor, Library, Share, Hotkeys) and one +color theme cover every window; settings are `%APPDATA%\Jiffshot\jiffshot.json` +and captures go to `Pictures\Jiffshot` unless changed. +[docs/architecture.md](docs/architecture.md) describes how it fits together. + +## Layout + +| Path | Contents | +|---|---| +| `crates/jiffshot-capture/` | Monitors and DPI, screen grabs, window targets, clipboard, OCR, media (recording and playback), the library, settings | +| `crates/jiffshot-doc/` | Documents: annotation objects, rendering, editing and undo, effects, smart tools, `.jsd` and image files | +| `crates/jiffshot-workflow/` | The capture side: tray, hotkeys, selection overlay, recording, capture panels, post-capture workflow, command-line modes | +| `app/` | `jiffshot.exe`: the editor's model (`src/app.rs`, view models in `src/vm.rs`), every window (`src/ui/`) and the capture side, in one process | + +## Building + +Jiffshot needs Rust 1.88+ and nothing else: it depends on Rustolonia's +`v12.1.2` release, and the first build downloads that release's prebuilt +host (checked against the checksums pinned at the tag). No .NET SDK or +Rustolonia checkout is involved. + +```powershell +cd apps/jiffshot +cargo run --release -p jiffshot +``` + +The build copies the host next to `jiffshot.exe`, so `target/release` runs as +it is; zip `jiffshot.exe` and the host DLLs beside it, with +`app/README-portable.txt`, for the portable app. + +The `apps/jiffshot` folder builds the same way outside this repository: copy +it anywhere. Its `.cargo/config.toml` only undoes this repository's +`RUSTOLONIA_NO_DOWNLOAD=1` for the folder. To move to a newer Rustolonia, +change the tag on the two lines in `Cargo.toml`'s `[workspace.dependencies]`. + +With no arguments Jiffshot starts in the tray. `jiffshot.exe FILE` opens a +file in the editor, `--capture`, `--record`, `--window` and the other capture +flags start a capture, and `--quit` ends it; a second launch hands its request +to the one already running. Closing the editor keeps Jiffshot in the tray. + +## Testing + +From `apps/jiffshot`: + +```powershell +cargo test --release --workspace # capture side, documents, media, library, editor +./target/release/jiffshot.exe --selftest DIR # overlay logic, offscreen +./target/release/jiffshot.exe --record-selftest DIR 4 # records its own test window +``` + +The editor drives its own UI when `JIFFSHOT_UITEST=DIR` is set (with +`JIFFSHOT_UITEST_IMAGE`, `JIFFSHOT_UITEST_VIDEO`, an isolated +`JIFFSHOT_LIBRARY` and, to leave your settings alone, scratch `APPDATA` and +`LOCALAPPDATA`), writing screenshots of every window and `ui-log.txt` to +`DIR`. `cargo run -p jiffshot-doc --example synthetic -- OUT.png` writes the +test image its coordinates assume. +The capture panels and outlines are left out of screen captures; set +`JIFFSHOT_HUD_CAPTURABLE=1` to see them in screenshots while checking them. +`JIFFSHOT_CAPTURE_DRY_RUN=1` runs the selection overlay for real but only logs +the selection (nothing is saved or copied), for checking it live; +`JIFFSHOT_RECORD_DRAW=1` and `JIFFSHOT_RECORD_SWITCH=1` make +`--record-selftest` check Screen Draw marks and switching sound and the +pointer part-way. diff --git a/apps/jiffshot/app/Cargo.toml b/apps/jiffshot/app/Cargo.toml new file mode 100644 index 0000000..1ca0c55 --- /dev/null +++ b/apps/jiffshot/app/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "jiffshot" +description = "Jiffshot, a screen capture tool and image and video editor" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +rustolonia.workspace = true +# Only for the build script to find the staged host (DEP_RUSTOLONIA_HOST_HOST_DIR). +rustolonia-sys.workspace = true +image = { version = "0.25", default-features = false, features = ["png", "gif"] } +tiny-skia.workspace = true +parking_lot = "0.12" +serde.workspace = true +serde_json.workspace = true +jiffshot-capture = { path = "../crates/jiffshot-capture" } +jiffshot-workflow = { path = "../crates/jiffshot-workflow" } +jiffshot-doc = { path = "../crates/jiffshot-doc" } +windows-sys = { workspace = true, features = ["Win32_Media_Audio", "Win32_Security_Credentials"] } diff --git a/apps/jiffshot/app/README-portable.txt b/apps/jiffshot/app/README-portable.txt new file mode 100644 index 0000000..b20cbaf --- /dev/null +++ b/apps/jiffshot/app/README-portable.txt @@ -0,0 +1,26 @@ +Jiffshot 0.1.0 (portable) +========================= + +Screen capture and image editor for Windows 10 and 11. + +Getting started +- Unzip this folder anywhere and run jiffshot.exe. Nothing is installed. +- Jiffshot lives in the notification area (the tray icon). Its menu + captures, records video, opens the editor and has Preferences. +- Print Screen captures (Ctrl+Alt+Print Screen while another app, such + as another capture tool or the Snipping Tool, holds Print Screen). Click + a window, drag an area, or press F1 while selecting for all keys. +- Closing the editor keeps Jiffshot in the tray; the tray's Exit quits. + +Where things go +- Captures: Pictures\Jiffshot (Preferences > Library to change it). +- Settings: %APPDATA%\Jiffshot\jiffshot.json. +- To remove Jiffshot, quit it and delete this folder (and those two, if + you like). If you turned on "Run when Windows starts", turn it off in + Preferences first. + +Notes +- Windows may warn that the app is from an unknown publisher: the build + is not code-signed. Choose More info > Run anyway. +- Video recording uses Windows Media Foundation, which Windows "N" + editions leave out (Media Feature Pack from Microsoft adds it back). diff --git a/apps/jiffshot/app/build.rs b/apps/jiffshot/app/build.rs new file mode 100644 index 0000000..d60d61b --- /dev/null +++ b/apps/jiffshot/app/build.rs @@ -0,0 +1,35 @@ +//! Embeds the program icon (see assets/icon_res.rs) and puts Rustolonia's +//! host next to `jiffshot.exe`, so the build output folder runs as it is and +//! can be zipped as the portable app. + +include!("../assets/icon_res.rs"); + +use std::path::PathBuf; + +fn main() { + embed_icon("../assets/jiffshot.ico"); + copy_host(); +} + +/// Copies the host directory `rustolonia-sys` staged (its native libraries) +/// into the profile directory the executable is linked into. +fn copy_host() { + let Some(host) = std::env::var_os("DEP_RUSTOLONIA_HOST_HOST_DIR").map(PathBuf::from) else { + println!("cargo:warning=no Rustolonia host was staged; copy it next to jiffshot.exe yourself"); + return; + }; + // OUT_DIR is //build/jiffshot-/out. + let out = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")); + let Some(profile) = out.ancestors().nth(3) else { return }; + let Ok(entries) = std::fs::read_dir(&host) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("dll") || e == "so" || e == "dylib") { + println!("cargo:rerun-if-changed={}", path.display()); + if let Err(error) = std::fs::copy(&path, profile.join(entry.file_name())) { + println!("cargo:warning=copying {}: {error}", path.display()); + } + } + } + println!("cargo:rerun-if-env-changed=RUSTOLONIA_HOST_DIR"); +} diff --git a/apps/jiffshot/app/file-associations/README.md b/apps/jiffshot/app/file-associations/README.md new file mode 100644 index 0000000..b95ff5a --- /dev/null +++ b/apps/jiffshot/app/file-associations/README.md @@ -0,0 +1,8 @@ +# File type association + +`windows-file-association.reg` registers `.jsd` (Jiffshot documents) so the +shell opens them with `jiffshot.exe`, which takes the file as a command-line +argument (a running Jiffshot opens it in its editor). It is packaging +metadata, not an installer: an installer writes the same keys, or +`reg import` applies them for local testing. Replace the install path in it +first. diff --git a/apps/jiffshot/app/file-associations/windows-file-association.reg b/apps/jiffshot/app/file-associations/windows-file-association.reg new file mode 100644 index 0000000..50d66c0 --- /dev/null +++ b/apps/jiffshot/app/file-associations/windows-file-association.reg @@ -0,0 +1,27 @@ +Windows Registry Editor Version 5.00 + +; File type association for jiffshot. +; +; Jiffshot documents (.jsd). Replace the install path with the real one. +; "%1" is what makes the shell pass the selected file to the executable; the +; Rust entry point forwards it to the managed desktop lifetime, so it arrives +; through AppScope::activation_items(). +; +; Written under HKEY_CURRENT_USER so it can be applied and undone without +; elevation while developing. A real installer writes the same keys under +; HKEY_LOCAL_MACHINE\Software\Classes and owns their removal on uninstall. + +[HKEY_CURRENT_USER\Software\Classes\.jsd] +@="jiffshot.Document" + +[HKEY_CURRENT_USER\Software\Classes\.jsd\OpenWithProgids] +"jiffshot.Document"="" + +[HKEY_CURRENT_USER\Software\Classes\jiffshot.Document] +@="Jiffshot document" + +[HKEY_CURRENT_USER\Software\Classes\jiffshot.Document\DefaultIcon] +@="C:\\Program Files\\jiffshot\\jiffshot.exe,0" + +[HKEY_CURRENT_USER\Software\Classes\jiffshot.Document\shell\open\command] +@="\"C:\\Program Files\\jiffshot\\jiffshot.exe\" \"%1\"" diff --git a/apps/jiffshot/app/src/app.rs b/apps/jiffshot/app/src/app.rs new file mode 100644 index 0000000..80a4882 --- /dev/null +++ b/apps/jiffshot/app/src/app.rs @@ -0,0 +1,2246 @@ +//! The editor application: view-model state, commands, files, the library +//! tray and quick styles. Runs on the UI thread; background work posts its +//! results back with [`post`]. +//! +//! The state lives in a thread-local `RefCell`. Model calls change state +//! and queue one [`App::sync`] on the dispatcher instead of publishing +//! directly: the window's model is busy during a call, and a published +//! value can make a control write a coerced value straight back (a slider +//! clamping to a new range), which would re-enter it. +//! Writes that arrive while a sync is publishing find the state borrowed +//! and are dropped, which is right for echoes. + +use crate::canvas::{with_editor, EDITOR, PENDING_EVENTS}; +use crate::vm::*; +use crate::{library, settings, winutil}; +use rustolonia::storage::{FileTypeFilter, OpenFilePickerOptions, PickerOutcome, SaveFilePickerOptions, WellKnownFolder}; +use rustolonia::{AppScope, Result}; +use jiffshot_capture::library::{edits_of, Library, EDITS_EXT}; +use jiffshot_doc::editor::{self as ed, Tool, EVENT_COLOR_PICKED}; +use jiffshot_doc::object::{Cap, Dash, Kind, Object, Style}; +use jiffshot_doc::text::Align; +use jiffshot_doc::{combine, effects, io, Document, Rgba}; +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex}; + +thread_local! { + static APP: RefCell> = const { RefCell::new(None) }; +} + +/// Runs `f` on the app state, unless it is already in use further up the +/// stack (a re-entrant echo from a window). +pub fn with(f: impl FnOnce(&mut App) -> R) -> Option { + APP.with(|cell| { + let mut guard = cell.try_borrow_mut().ok()?; + guard.as_mut().map(f) + }) +} + +/// Like [`with`], then queues publishing whatever changed. +pub fn act(f: impl FnOnce(&mut App)) -> Result<()> { + let _t = crate::trace::span("act"); + with(f); + schedule_sync(); + Ok(()) +} + +static SYNC_QUEUED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Publishes changes once the current dispatcher job (and the model lock +/// it may hold) is done. Several requests before then share one sync. +pub fn schedule_sync() { + if SYNC_QUEUED.swap(true, Ordering::AcqRel) { + return; + } + let scope = SCOPE.lock().unwrap().clone(); + if let Some(scope) = scope { + let _ = scope.post(|| { + SYNC_QUEUED.store(false, Ordering::Release); + with(|app| app.sync()); + }); + } +} + +/// Runs `f` on the UI thread with the app state, from any thread. +pub fn post(f: impl FnOnce(&mut App) + Send + 'static) { + let scope = SCOPE.lock().unwrap().clone(); + if let Some(scope) = scope { + let _ = scope.post(move || { + let _ = act(f); + }); + } +} + +/// The capture side's latest HUD, not yet shown. +static PENDING_HUD: Mutex> = Mutex::new(None); + +/// Shows the capture side's HUD (`json`), from any thread. While recording +/// it changes every tick: only the latest is shown, so a UI thread that +/// falls behind skips states instead of queueing them, and showing one does +/// not sync the editor. +pub fn post_hud(json: String) { + let queued = PENDING_HUD.lock().unwrap().replace(json).is_some(); + if queued { + return; + } + let scope = SCOPE.lock().unwrap().clone(); + if let Some(scope) = scope { + let _ = scope.post(|| { + let Some(json) = PENDING_HUD.lock().unwrap().take() else { return }; + if with(|app| app.set_hud(&json)).is_none() { + // The state is in use further up the stack: next turn. The + // last HUD (all panels gone) must not be lost. + post_hud(json); + } + }); + } +} + +/// Runs `f` on the UI thread without syncing the editor afterwards, for +/// changes that only reach other windows (the overlay, Screen Draw). +pub fn post_quiet(f: impl FnOnce(&mut App) + Send + 'static) { + let scope = SCOPE.lock().unwrap().clone(); + if let Some(scope) = scope { + let _ = scope.post(move || { + with(f); + }); + } +} + +static SCOPE: Mutex> = Mutex::new(None); + +pub fn install(scope: AppScope, opening: Option) { + *SCOPE.lock().unwrap() = Some(scope.clone()); + let settings = settings::Settings::load(); + let root = jiffshot_capture::config::library_root(); + let library = Library::new(root); + APP.with(|cell| { + *cell.borrow_mut() = Some(App { + scope, + sink: None, + shown: Default::default(), + path: None, + source: None, + library, + exiting: false, + toasts: 0, + settings, + frame: 0, + focus: 0, + zoom: 0, + status: String::new(), + object_clipboard: None, + library_items: Vec::new(), + library_selected: Default::default(), + library_entries: Vec::new(), + shown_filters: Vec::new(), + canvas_drop: None, + opening, + last_tool: None, + last_kind: None, + video: None, + narration: None, + }) + }); +} + +/// Values last sent to the windows, so unchanged ones are not resent. +#[derive(Default)] +pub struct Shown { + strings: std::collections::HashMap<&'static str, String>, + numbers: std::collections::HashMap<&'static str, f64>, + flags: std::collections::HashMap<&'static str, bool>, +} + +pub struct App { + scope: AppScope, + /// Set once Exit started, so the capture side stopping does not start it again. + exiting: bool, + /// Toasts shown so far, so each one is a new value of `Toast`. + toasts: u64, + pub(crate) sink: Option, + shown: Shown, + /// Where Save writes: a `.jsd` file, or `None` for a new document. + path: Option, + /// The image or capture the document was opened from. + source: Option, + pub library: Library, + pub settings: settings::Settings, + frame: i64, + focus: i64, + zoom: i64, + status: String, + object_clipboard: Option, + library_items: Vec, + library_selected: std::collections::HashSet, + library_entries: Vec, + shown_filters: Vec, + /// When images were last dropped onto the canvas. + canvas_drop: Option, + opening: Option, + last_tool: Option, + last_kind: Option, + pub video: Option, + pub narration: Option, +} + +fn opacity_percent(style: &Style) -> f64 { + (style.opacity * 100.0).round() as f64 +} + +fn align_name(a: Align) -> &'static str { + match a { + Align::Left => "Left", + Align::Center => "Center", + Align::Right => "Right", + } +} + +fn cap_name(c: Cap) -> &'static str { + c.name() +} + +fn dash_name(d: Dash) -> &'static str { + match d { + Dash::Solid => "Solid", + Dash::Dash => "Dash", + Dash::Dot => "Dot", + } +} + +/// Sections of the properties panel for a tool or selected object kind. +fn panel_for(kind: &str) -> &'static str { + match kind { + "Arrow" | "Line" => "stroke;width;caps;dash;shadow;opacity", + "Shape" => "variant;stroke;fill;gradient;width;dash;shadow;opacity;rotation", + "Text" => "text;font;textopts;textcolor;fill;gradient;stroke;width;shadow;opacity;rotation", + "Callout" => "variant;tails;text;font;textopts;textcolor;fill;gradient;stroke;width;shadow;opacity;rotation", + "Step" => "variant;text;font;textcolor;fill;gradient;stroke;width;shadow", + "Pen" | "Marker" => "variant;amount;stroke;width;opacity;shadow", + "Highlight" => "variant;fill;width", + "Blur" => "variant;amount;fill", + "Magnify" => "variant;amount;stroke;width;shadow", + "Spotlight" => "variant;amount", + "Stamp" => "variant;shadow;opacity;rotation", + "Image" => "stroke;width;shadow;opacity;rotation", + "Drop zone" => "zone;stroke;width;shadow", + "Fill" => "fill;amount", + "Cut Out" => "variant", + "Selection" => "variant;backfill", + "Magic Wand" => "amount;backfill", + "Smart Move" => "backfill", + "Eraser" => "variant;width", + "Group" => "shadow;opacity", + _ => "", + } +} + +fn amount_label(kind: &str) -> &'static str { + match kind { + "Blur" => "Intensity", + "Magnify" => "Zoom", + "Spotlight" => "Dim", + "Magic Wand" | "Fill" => "Tolerance", + "Pen" | "Marker" => "Smoothing", + _ => "", + } +} + +/// What the properties panel shows, read from the editor in one go. +struct Snapshot { + tool: Tool, + kind: Option, + style: Style, + text: String, + variant: String, + variants: Vec, + amount: f64, + rotation: f64, + size: (u32, u32), + objects: usize, + undo: Option, + redo: Option, + modified: bool, + damage: bool, + picked: Option, + selected_count: usize, + background_fill: &'static str, +} + +impl App { + pub fn attach(&mut self, sink: MainViewModelSink) -> Result<()> { + for f in jiffshot_doc::text::FAMILIES { + sink.add_fonts(*f)?; + } + for (name, _) in effects::catalog(Rgba::BLACK) { + sink.add_effects(name)?; + } + for name in effects::INTERACTIVE { + sink.add_effects(*name)?; + } + for name in crate::share::BUILT_IN { + sink.add_shares(*name)?; + } + for (name, _) in &self.settings.programs { + sink.add_shares(name)?; + } + sink.add_shares("Add program…")?; + for path in self.settings.recent.iter().filter(|p| p.exists()) { + sink.add_recent_files(file_uri(path))?; + } + for t in ["Combine side by side", "Combine stacked", "Combine grid"].iter().chain(combine::TEMPLATES) { + sink.add_templates(*t)?; + } + for t in combine::THEMES { + sink.add_template_themes(t.name)?; + } + self.sink = Some(sink); + if let Some(window) = self.scope.main_window() { + use rustolonia::storage::{DragDropEffects, FileDropEvent}; + let _ = self.scope.on_file_drop(&window, DragDropEffects::COPY, |event| { + if let FileDropEvent::Drop { items, .. } = event { + let paths: Vec = items.iter().filter_map(|i| i.local_path().map(Path::to_path_buf)).collect(); + post(move |app| { + // Files dropped on the canvas were placed there. + if app.canvas_drop.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(2)) { + return; + } + if let Some(first) = paths.first() { + app.open_path(first); + } + }); + } + }); + } + if let Some(path) = self.opening.take() { + self.open_path(&path); + } else { + self.set_status("Capture with Print Screen or the tray icon, open an image, or paste one."); + } + self.refresh_library(); + self.load_stamps(); + schedule_sync(); + Ok(()) + } + + pub fn detach(&mut self) { + self.autosave(); + self.sink = None; + } + + pub fn set_status(&mut self, text: impl Into) { + self.status = text.into(); + } + + // ---- publishing ---------------------------------------------------- + + fn put_str(&mut self, name: &'static str, value: &str, set: impl Fn(&MainViewModelSink, &str) -> Result<()>) { + if self.shown.strings.get(name).map(String::as_str) != Some(value) { + if let Some(sink) = &self.sink { + let _ = set(sink, value); + } + self.shown.strings.insert(name, value.to_owned()); + } + } + + fn put_num(&mut self, name: &'static str, value: f64, set: impl Fn(&MainViewModelSink, f64) -> Result<()>) { + if self.shown.numbers.get(name) != Some(&value) { + if let Some(sink) = &self.sink { + let _ = set(sink, value); + } + self.shown.numbers.insert(name, value); + } + } + + fn put_flag(&mut self, name: &'static str, value: bool, set: impl Fn(&MainViewModelSink, bool) -> Result<()>) { + if self.shown.flags.get(name) != Some(&value) { + if let Some(sink) = &self.sink { + let _ = set(sink, value); + } + self.shown.flags.insert(name, value); + } + } + + fn snapshot(&self) -> Option { + let _t = crate::trace::span("snapshot"); + let mut guard = EDITOR.lock(); + let e = guard.as_mut()?; + let selected = e.selected().cloned(); + let tool = e.tool(); + let kind = selected.as_ref().map(|o| if o.zone.is_some() { "Drop zone".to_owned() } else { o.kind_name().to_owned() }); + let variant_tool = selected.as_ref().map(ed::tool_for).unwrap_or(tool); + let variant = selected.as_ref().and_then(ed::variant_of).unwrap_or_else(|| e.variant(tool)); + Some(Snapshot { + tool, + kind, + style: e.current_style(), + text: selected.as_ref().and_then(|o| o.text().map(str::to_owned)).unwrap_or_default(), + variant, + variants: e.variants_for(variant_tool), + amount: e.selected_amount().unwrap_or_else(|| e.tool_amount(tool)) as f64, + rotation: selected.as_ref().map_or(0.0, |o| o.rotation as f64), + size: e.document().size(), + objects: e.document().objects.len(), + undo: e.undo_label().map(str::to_owned), + redo: e.redo_label().map(str::to_owned), + modified: e.is_modified(), + damage: e.has_damage(), + picked: e.picked_color(), + selected_count: e.selected_ids().len(), + background_fill: e.background_fill.name(), + }) + } + + /// Publishes everything that changed since the last call. + pub fn sync(&mut self) { + let _t = crate::trace::span("sync"); + let events = PENDING_EVENTS.swap(0, Ordering::Relaxed) | with_editor(|e| e.take_events()).unwrap_or(0); + let Some(s) = self.snapshot() else { return }; + if let Some(color) = s.picked { + let hex = color.to_hex(); + self.set_status(format!("Picked {hex} — it is now the stroke color.")); + with_editor(|e| { + let prev = e.tool(); + e.set_tool(Tool::Arrow); + e.update_style("stroke", |st| st.stroke = color); + e.set_tool(prev); + }); + } + let _ = events & EVENT_COLOR_PICKED; + let kind_name = s.kind.clone().unwrap_or_else(|| s.tool.name().to_owned()); + self.put_str("Tool", s.tool.name(), |k, v| k.set_tool(v)); + self.put_str("Selection", s.kind.as_deref().unwrap_or(""), |k, v| k.set_selection(v)); + self.put_str("Panel", panel_for(&kind_name), |k, v| k.set_panel(v)); + self.put_str("AmountLabel", amount_label(&kind_name), |k, v| k.set_amount_label(v)); + if self.last_tool != Some(s.tool) || self.last_kind != s.kind { + self.last_tool = Some(s.tool); + self.last_kind = s.kind.clone(); + if let Some(sink) = &self.sink { + let _ = sink.clear_variants(); + for v in &s.variants { + let _ = sink.add_variants(v); + } + } + self.shown.strings.remove("Variant"); + self.publish_quick_styles(&kind_name); + } + let st = &s.style; + self.put_str("StrokeColor", &st.stroke.to_hex(), |k, v| k.set_stroke_color(v)); + self.put_str("FillColor", &st.fill.to_hex(), |k, v| k.set_fill_color(v)); + self.put_str("TextColor", &st.text_color.to_hex(), |k, v| k.set_text_color(v)); + self.put_num("StrokeWidth", st.width as f64, |k, v| k.set_stroke_width(v)); + self.put_num("FontSize", st.font_size.round() as f64, |k, v| k.set_font_size(v)); + self.put_str("FontFamily", &st.font, |k, v| k.set_font_family(v)); + self.put_flag("Bold", st.bold, |k, v| k.set_bold(v)); + self.put_flag("Italic", st.italic, |k, v| k.set_italic(v)); + self.put_flag("Shadow", st.shadow, |k, v| k.set_shadow(v)); + self.put_num("Opacity", opacity_percent(st), |k, v| k.set_opacity(v)); + self.put_str("Align", align_name(st.align), |k, v| k.set_align(v)); + self.put_str("Dash", dash_name(st.dash), |k, v| k.set_dash(v)); + self.put_str("StartCap", cap_name(st.start_cap), |k, v| k.set_start_cap(v)); + self.put_str("EndCap", cap_name(st.end_cap), |k, v| k.set_end_cap(v)); + self.put_str("Text", &s.text, |k, v| k.set_text(v)); + self.put_str("Variant", &s.variant, |k, v| k.set_variant(v)); + self.put_num("Amount", (s.amount * 100.0).round() / 100.0, |k, v| k.set_amount(v)); + self.put_num("Rotation", s.rotation.round(), |k, v| k.set_rotation(v)); + let info = format!("{} × {} px · {} objects", s.size.0, s.size.1, s.objects); + self.put_str("DocumentInfo", &info, |k, v| k.set_document_info(v)); + let name = self + .path + .as_ref() + .or(self.source.as_ref()) + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Untitled".to_owned()); + let title = format!("{}{name} — Jiffshot Editor", if s.modified { "• " } else { "" }); + self.put_str("Title", &title, |k, v| k.set_title(v)); + self.mark_unsaved(); + let status = self.status.clone(); + self.put_str("Status", &status, |k, v| k.set_status(v)); + let has_sel = s.kind.is_some(); + self.put_flag("UndoEnabled", s.undo.is_some(), |k, v| k.set_undo_enabled(v)); + self.put_flag("RedoEnabled", s.redo.is_some(), |k, v| k.set_redo_enabled(v)); + for (name, set) in [ + ("DeleteEnabled", MainViewModelSink::set_delete_enabled as fn(&MainViewModelSink, bool) -> Result<()>), + ("DuplicateEnabled", MainViewModelSink::set_duplicate_enabled), + ("CutEnabled", MainViewModelSink::set_cut_enabled), + ("BringToFrontEnabled", MainViewModelSink::set_bring_to_front_enabled), + ("BringForwardEnabled", MainViewModelSink::set_bring_forward_enabled), + ("SendBackwardEnabled", MainViewModelSink::set_send_backward_enabled), + ("SendToBackEnabled", MainViewModelSink::set_send_to_back_enabled), + ] { + self.put_flag(name, has_sel, set); + } + self.put_str("BackgroundFill", s.background_fill, |k, v| k.set_background_fill(v)); + let theme = self.settings.theme.clone().unwrap_or_else(|| "Dark".to_owned()); + self.put_str("Theme", &theme, |k, v| k.set_theme(v)); + let tools = self.settings.toolbar.join(","); + self.put_str("ToolOrder", &tools, |k, v| k.set_tool_order(v)); + self.publish_layout(); + let tp = self.settings.template.clone(); + self.put_str("TemplateTheme", combine::theme(&tp.theme).name, |k, v| k.set_template_theme(v)); + self.put_flag("TemplateCaptions", tp.captions, |k, v| k.set_template_captions(v)); + self.put_flag("TemplateNumbers", tp.numbers, |k, v| k.set_template_numbers(v)); + self.put_str("TemplateFooter", &tp.footer, |k, v| k.set_template_footer(v)); + let fit = with_editor(|e| e.selected().and_then(|o| o.zone).is_some_and(|z| z.fit)).unwrap_or(false); + self.put_flag("ZoneFit", fit, |k, v| k.set_zone_fit(v)); + let vp = self.settings.video.clone(); + self.put_str("GifPreset", &vp.gif_preset, |k, v| k.set_gif_preset(v)); + self.put_flag("GifLoop", vp.gif_loop, |k, v| k.set_gif_loop(v)); + self.put_flag("GifFade", vp.gif_fade, |k, v| k.set_gif_fade(v)); + self.put_flag("GifDither", vp.gif_dither, |k, v| k.set_gif_dither(v)); + self.put_num("VideoVolume", vp.volume, |k, v| k.set_video_volume(v)); + self.put_flag("Muted", vp.muted, |k, v| k.set_muted(v)); + self.put_num("SelectionCount", s.selected_count as f64, |k, v| k.set_selection_count(v as i64)); + if s.selected_count > 1 { + let note = format!("{} objects selected", s.selected_count); + self.put_str("Selection", ¬e, |k, v| k.set_selection(v)); + } + self.put_num("HeadSize", st.head_size as f64, |k, v| k.set_head_size(v)); + self.put_num("ShadowAngle", st.shadow_angle as f64, |k, v| k.set_shadow_angle(v)); + self.put_num("ShadowDistance", st.shadow_distance as f64, |k, v| k.set_shadow_distance(v)); + self.put_num("ShadowBlur", st.shadow_blur as f64, |k, v| k.set_shadow_blur(v)); + self.put_str("ShadowColor", &st.shadow_color.to_hex(), |k, v| k.set_shadow_color(v)); + self.put_str("OutlineColor", &st.outline.to_hex(), |k, v| k.set_outline_color(v)); + self.put_num("OutlineWidth", st.outline_width as f64, |k, v| k.set_outline_width(v)); + self.put_flag("Underline", st.underline, |k, v| k.set_underline(v)); + self.put_flag("Strike", st.strike, |k, v| k.set_strike(v)); + let valign = match st.valign { + jiffshot_doc::object::VAlign::Top => "Top", + jiffshot_doc::object::VAlign::Middle => "Middle", + jiffshot_doc::object::VAlign::Bottom => "Bottom", + }; + self.put_str("VAlign", valign, |k, v| k.set_v_align(v)); + self.put_num("Padding", st.padding as f64, |k, v| k.set_padding(v)); + self.put_str("Fill2", &st.fill2.to_hex(), |k, v| k.set_fill2(v)); + self.put_flag("GradientRadial", st.gradient_radial, |k, v| k.set_gradient_radial(v)); + self.put_num("GradientAngle", st.gradient_angle as f64, |k, v| k.set_gradient_angle(v)); + let video = self.video.is_some(); + self.put_str("Mode", if video { "Video" } else { "Image" }, |k, v| k.set_mode(v)); + if events & ed::EVENT_FOCUS_TEXT != 0 { + self.focus += 1; + let focus = self.focus; + if let Some(sink) = &self.sink { + let _ = sink.set_focus_text(focus); + } + } + if s.damage { + self.bump_frame(); + } + } + + /// Tells the canvas to fetch pending damage. + pub fn bump_frame(&mut self) { + self.frame += 1; + if let Some(sink) = &self.sink { + let _ = sink.set_frame_id(self.frame); + } + } + + pub(crate) fn zoom(&mut self, what: &str) { + self.zoom += 1; + let request = format!("{what}:{}", self.zoom); + if let Some(sink) = &self.sink { + let _ = sink.set_zoom_request(request); + } + } + + // ---- documents -------------------------------------------------------- + + /// Saves edits of library captures automatically, next to the capture. + pub fn autosave(&mut self) { + let modified = with_editor(|e| e.is_modified()).unwrap_or(false); + if !modified { + return; + } + let target = match (&self.path, &self.source) { + (Some(p), _) => Some(p.clone()), + (None, Some(src)) if src.starts_with(&self.library.root) => Some(src.with_extension(EDITS_EXT)), + _ => None, + }; + if let Some(path) = target { + self.save_to(&path); + } + } + + fn save_to(&mut self, path: &Path) -> bool { + let doc = with_editor(|e| e.document().clone()); + let Some(doc) = doc else { return false }; + let is_doc = io::is_document(path); + let result = if is_doc { io::save_document(&doc, path) } else { io::export(&doc, path) }; + match result { + Ok(()) => { + if is_doc { + self.path = Some(path.to_path_buf()); + with_editor(|e| e.mark_saved()); + library::write_thumbnail(&doc, self.source.as_deref().unwrap_or(path)); + self.refresh_library(); + } + self.set_status(format!("Saved {}", path.display())); + true + } + Err(error) => { + self.set_status(format!("Could not save {}: {error}", path.display())); + false + } + } + } + + pub(crate) fn load(&mut self, doc: Document, path: Option, source: Option) { + self.autosave(); + self.stop_video(); + let size = doc.size(); + with_editor(|e| e.load(doc)); + self.path = path; + self.source = source; + self.zoom("fit"); + self.bump_frame(); + self.mark_current(); + self.set_status(format!("{} × {} px", size.0, size.1)); + } + + /// The Share list: the built-in destinations, the user's programs, and + /// Add program. + pub(crate) fn publish_shares(&mut self) { + let Some(sink) = &self.sink else { return }; + let _ = sink.clear_shares(); + for n in crate::share::BUILT_IN { + let _ = sink.add_shares(*n); + } + for (n, _) in &self.settings.programs { + let _ = sink.add_shares(n); + } + let _ = sink.add_shares("Add program…"); + } + + /// Puts `path` first in File ▸ Open recent. + fn remember_recent(&mut self, path: &Path) { + let recent = &mut self.settings.recent; + recent.retain(|p| p != path); + recent.insert(0, path.to_path_buf()); + recent.truncate(10); + self.settings.save(); + self.publish_recent(); + } + + pub(crate) fn publish_recent(&mut self) { + let Some(sink) = &self.sink else { return }; + let _ = sink.clear_recent_files(); + for path in self.settings.recent.iter().filter(|p| p.exists()) { + let _ = sink.add_recent_files(file_uri(path)); + } + } + + pub fn open_recent(&mut self, uri: &str) { + let path = path_of_uri(uri); + if path.exists() { + self.open_path(&path); + } else { + self.settings.recent.retain(|p| *p != path); + self.settings.save(); + self.publish_recent(); + self.set_status(format!("{} is gone", path.display())); + } + } + + /// Image ▸ Auto trim: removes edges of one solid color. + pub fn auto_trim(&mut self) { + let Some(r) = with_editor(|e| trim_bounds(e.document().background())) else { return }; + match r { + Some(r) => self.document_op("Auto trim", |d| d.crop(r)), + None => self.set_status("No solid-colored edges to trim"), + } + } + + /// Resize Image: to `w` x `h` pixels. + pub fn resize_to(&mut self, w: u32, h: u32) { + if w == 0 || h == 0 || w > 16384 || h > 16384 { + return self.set_status("Sizes go from 1 to 16384 pixels"); + } + self.document_op("Resize", |d| { + if d.size() == (w, h) { + return false; + } + d.scale(w, h); + true + }); + } + + /// Trim: removes this many pixels from each side. + pub fn trim(&mut self, left: u32, top: u32, right: u32, bottom: u32) { + let (w, h) = with_editor(|e| e.document().size()).unwrap_or_default(); + if left + right >= w || top + bottom >= h { + return self.set_status("That would trim away the whole image"); + } + let r = jiffshot_doc::IRect::new(left as i32, top as i32, (w - left - right) as i32, (h - top - bottom) as i32); + self.document_op("Trim", |d| d.crop(r)); + } + + pub fn open_path(&mut self, path: &Path) { + let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).unwrap_or_default(); + if ext == "mp4" { + return self.open_video(path); + } + // A library capture with saved edits opens the edits. + let is_doc = io::is_document(path); + let (file, doc_path) = match (!is_doc).then(|| edits_of(path)).flatten() { + Some(edits) => (edits.clone(), Some(edits)), + None => (path.to_path_buf(), is_doc.then(|| path.to_path_buf())), + }; + let started = std::time::Instant::now(); + match io::open(&file) { + Ok(mut doc) => { + if doc_path.is_none() { + add_cursor_object(&mut doc, path); + } + let source = if is_doc { None } else { Some(path.to_path_buf()) }; + self.load(doc, doc_path, source); + self.remember_recent(path); + let ms = started.elapsed().as_secs_f64() * 1000.0; + let (w, h) = with_editor(|e| e.document().size()).unwrap_or_default(); + self.set_status(format!("Opened {} ({w} × {h}) in {ms:.0} ms", file.display())); + } + Err(error) => self.set_status(format!("Could not open {}: {error}", file.display())), + } + } + + fn window(&self) -> Option { + self.scope.main_window() + } + + /// Runs a file picker and hands the chosen paths to `then` on the UI + /// thread. + fn pick_open(&mut self, title: &str, multiple: bool, images_only: bool, then: impl FnOnce(&mut App, Vec) + Send + 'static) { + let Some(window) = self.window() else { return }; + let mut filter = FileTypeFilter::new(if images_only { "Images" } else { "Jiffshot and image files" }); + let exts: &[&str] = if images_only { &["png", "jpg", "jpeg", "bmp", "gif", "webp"] } else { io::OPEN_EXTENSIONS }; + for e in exts.iter().chain((!images_only).then_some(&"mp4")) { + filter = filter.with_extension(e); + } + let options = OpenFilePickerOptions::new().title(title).allow_multiple(multiple).start_in(WellKnownFolder::Pictures).file_type(filter); + let op = match self.scope.open_file_picker(&window, &options) { + Ok(op) => op, + Err(error) => return self.set_status(format!("File picker failed: {error}")), + }; + let _ = self.scope.spawn(async move { + if let Ok(PickerOutcome::Selected(items)) = op.await { + let paths: Vec = items.iter().filter_map(|i| i.local_path().map(Path::to_path_buf)).collect(); + post(move |app| then(app, paths)); + } + }); + } + + pub(crate) fn pick_save(&mut self, title: &str, name: &str, kinds: &[(&str, &str)], then: impl FnOnce(&mut App, PathBuf) + Send + 'static) { + let Some(window) = self.window() else { return }; + let mut options = SaveFilePickerOptions::new().title(title).suggested_file_name(name).show_overwrite_prompt(true).start_in(WellKnownFolder::Pictures); + for (label, ext) in kinds { + options = options.file_type(FileTypeFilter::new(*label).with_extension(ext)); + } + if let Some((_, ext)) = kinds.first() { + options = options.default_extension(*ext); + } + let op = match self.scope.save_file_picker(&window, &options) { + Ok(op) => op, + Err(error) => return self.set_status(format!("File picker failed: {error}")), + }; + let _ = self.scope.spawn(async move { + if let Ok(PickerOutcome::Selected(items)) = op.await { + if let Some(path) = items.first().and_then(|i| i.local_path().map(Path::to_path_buf)) { + post(move |app| then(app, path)); + } + } + }); + } + + fn stem(&self) -> String { + self.path + .as_ref() + .or(self.source.as_ref()) + .and_then(|p| p.file_stem()) + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Untitled".into()) + } + + // ---- commands ---------------------------------------------------------- + + pub fn new_image(&mut self) { + self.load(Document::blank(1280, 720, Rgba::WHITE), None, None); + } + + pub fn open(&mut self) { + self.pick_open("Open", false, false, |app, paths| { + if let Some(p) = paths.first() { + app.open_path(p); + } + }); + } + + pub fn save(&mut self) { + let target = match (&self.path, &self.source) { + (Some(p), _) => Some(p.clone()), + (None, Some(src)) if src.starts_with(&self.library.root) => Some(src.with_extension(EDITS_EXT)), + _ => None, + }; + match target { + Some(path) => { + self.save_to(&path); + } + None => self.save_as(), + } + } + + pub fn save_as(&mut self) { + let name = format!("{}.{EDITS_EXT}", self.stem()); + let kinds = [("Jiffshot document", EDITS_EXT), ("PNG image", "png"), ("JPEG image", "jpg"), ("BMP image", "bmp"), ("GIF image", "gif"), ("WebP image", "webp")]; + self.pick_save("Save as", &name, &kinds, |app, path| { + app.save_to(&path); + }); + } + + pub fn export(&mut self) { + let mut kinds = vec![ + ("PNG image", "png"), + ("JPEG image", "jpg"), + ("PDF document", "pdf"), + ("BMP image", "bmp"), + ("GIF image", "gif"), + ("WebP image", "webp"), + ]; + // The format used last comes first. + if let Some(i) = self.settings.export_format.as_deref().and_then(|f| kinds.iter().position(|k| k.1 == f)) { + let k = kinds.remove(i); + kinds.insert(0, k); + } + let name = format!("{}.{}", self.stem(), kinds[0].1); + self.pick_save("Export image", &name, &kinds, |app, path| { + let doc = with_editor(|e| e.document().clone()); + let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).unwrap_or_default(); + if let Some(doc) = doc { + match io::export(&doc, &path) { + Ok(()) => { + app.settings.export_format = Some(ext); + app.settings.save(); + app.set_status(format!("Exported {}", path.display())); + } + Err(error) => app.set_status(format!("Export failed: {error}")), + } + } + }); + } + + pub fn copy_image(&mut self) { + let Some(flat) = with_editor(|e| e.document().flatten()) else { return }; + let frame = winutil::frame_from_pixmap(&flat); + match jiffshot_capture::clipboard::copy(&frame, winutil::main_hwnd()) { + Ok(()) => self.set_status(format!("Copied the image ({} × {}) to the clipboard", flat.width(), flat.height())), + Err(error) => self.set_status(format!("Copy failed: {error}")), + } + } + + pub fn copy(&mut self) { + match with_editor(|e| e.selected().cloned()).flatten() { + Some(o) => { + self.set_status(format!("Copied the {}", o.kind_name().to_lowercase())); + self.object_clipboard = Some(o); + } + None => self.copy_image(), + } + } + + pub fn cut(&mut self) { + self.copy(); + with_editor(|e| e.delete_selected()); + } + + pub fn paste(&mut self) { + use jiffshot_capture::clipboard::Pasted; + match jiffshot_capture::clipboard::read(winutil::main_hwnd()) { + Ok(Some(Pasted::Image { width, height, rgba })) => { + let img = image::RgbaImage::from_raw(width, height, rgba).and_then(|i| io::from_rgba_image(&i)); + if let Some(pm) = img { + with_editor(|e| e.paste_image(pm)); + self.object_clipboard = None; + self.set_status(format!("Pasted a {width} × {height} image")); + } + } + Ok(Some(Pasted::Files(files))) => { + let mut n = 0; + for f in files { + if let Ok(pm) = io::load_image(&f) { + with_editor(|e| e.paste_image(pm)); + n += 1; + } + } + self.set_status(format!("Pasted {n} image file(s)")); + } + Ok(Some(Pasted::Text(text))) if self.object_clipboard.is_none() => { + with_editor(|e| { + let (w, h) = e.document().size(); + let style = Style { ..e.tool_style(Tool::Text) }; + let rect = jiffshot_doc::Box2::from_xywh(w as f32 * 0.1, h as f32 * 0.1, w as f32 * 0.5, style.font_size * 2.0); + let id = e.add_object("Paste text", Kind::Text { rect, text: String::new() }, style); + let _ = id; + e.set_selected_text(&text); + }); + } + _ => match self.object_clipboard.clone() { + Some(o) => { + with_editor(|e| e.paste_object(o)); + } + None => self.set_status("Nothing to paste"), + }, + } + } + + pub fn new_capture(&mut self) { + match winutil::start_capture() { + Ok(()) => self.set_status("Select what to capture…"), + Err(error) => self.set_status(error), + } + } + + /// Opens the Capture window; the editor stays open. + pub fn open_capture_window(&mut self) { + if let Err(error) = winutil::open_capture_window() { + self.set_status(error); + } + } + + pub fn combine_images(&mut self) { + self.template("Combine grid"); + } + + /// Images (or a capture to open) dropped at a canvas point: + /// "x,y|path|path…". Several images fill the next empty drop zones. + fn drop_files(&mut self, arg: &str) { + self.canvas_drop = Some(std::time::Instant::now()); + let mut parts = arg.split('|'); + let at = parts.next().and_then(|xy| xy.split_once(',')).and_then(|(x, y)| Some(jiffshot_doc::Pt::new(x.parse().ok()?, y.parse().ok()?))); + let paths: Vec = parts.filter(|p| !p.is_empty()).map(PathBuf::from).collect(); + let Some(at) = at else { return }; + let images: Vec<_> = paths.iter().filter_map(|p| io::load_image(p).ok()).collect(); + if images.is_empty() { + if let Some(first) = paths.first() { + self.open_path(first); + } + return; + } + let n = images.len(); + with_editor(|e| { + let mut images = images.into_iter(); + if let Some(first) = images.next() { + e.drop_image(at, first); + } + for img in images { + let empty = e.document().objects.iter().find(|o| o.zone.is_some() && matches!(&o.kind, jiffshot_doc::object::Kind::Image { image, .. } if image.pixmap.is_none())).map(|o| o.id); + match empty { + Some(id) => e.fill_zone(id, img), + None => { + e.drop_image(jiffshot_doc::Pt::new(at.x + 24.0, at.y + 24.0), img); + } + } + } + }); + self.set_status(format!("Placed {n} image{}", if n == 1 { "" } else { "s" })); + } + + /// Chooses a picture for the selected drop zone. + fn pick_zone_image(&mut self) { + let zone = with_editor(|e| e.selected().filter(|o| o.zone.is_some()).map(|o| o.id)).flatten(); + let Some(id) = zone else { return }; + self.pick_open("Choose an image for the drop zone", false, true, move |app, paths| { + if let Some(img) = paths.first().and_then(|p| io::load_image(p).ok()) { + with_editor(|e| e.fill_zone(id, img)); + app.bump_frame(); + } + }); + } + + fn template_options(&self) -> combine::Options { + let t = &self.settings.template; + combine::Options { + theme: combine::theme(&t.theme), + captions: t.captions, + numbers: t.numbers, + footer: Some(t.footer.clone()).filter(|f| !f.trim().is_empty()), + } + } + + /// Changes a template choice; the next template uses it. + pub fn template_prefs(&mut self, change: impl FnOnce(&mut crate::settings::TemplatePrefs)) { + change(&mut self.settings.template); + self.settings.save(); + schedule_sync(); + } + + fn template(&mut self, name: &str) { + let name = name.to_owned(); + // Captures picked in the library go in; otherwise templates start + // with empty drop zones and combining asks for images. + let picked: Vec<_> = self.library_items.iter().filter(|i| self.library_selected.contains(&i.path)).filter_map(|i| io::load_image(&i.path).ok()).collect(); + if !name.starts_with("Combine") && (!picked.is_empty() || self.library_selected.is_empty()) { + if let Some(doc) = combine::template_with(&name, &picked, &self.template_options()) { + self.load(doc, None, None); + with_editor(|e| e.checkpoint("Template", None)); + self.set_status(format!("{name}: drop images from the tray or Explorer onto the drop zones")); + } + return; + } + if name.starts_with("Combine") && picked.len() >= 2 { + let arrangement = match name.as_str() { + "Combine side by side" => combine::Arrangement::Horizontal, + "Combine stacked" => combine::Arrangement::Vertical, + _ => combine::Arrangement::Grid, + }; + if let Some(doc) = combine::combine(&picked, arrangement, 24.0, Rgba::WHITE) { + self.load(doc, None, None); + with_editor(|e| e.checkpoint("Combine", None)); + } + return; + } + let options = self.template_options(); + self.pick_open("Choose images", true, true, move |app, paths| { + let images: Vec<_> = paths.iter().filter_map(|p| io::load_image(p).ok()).collect(); + if images.is_empty() { + return; + } + let doc = match name.as_str() { + "Combine side by side" => combine::combine(&images, combine::Arrangement::Horizontal, 24.0, Rgba::WHITE), + "Combine stacked" => combine::combine(&images, combine::Arrangement::Vertical, 24.0, Rgba::WHITE), + "Combine grid" => combine::combine(&images, combine::Arrangement::Grid, 24.0, Rgba::WHITE), + other => combine::template_with(other, &images, &options), + }; + if let Some(doc) = doc { + app.load(doc, None, None); + with_editor(|e| e.checkpoint("Template", None)); + app.set_status(format!("{name}: {} image(s)", images.len())); + } + }); + } + + pub fn open_library_folder(&mut self) { + let _ = std::fs::create_dir_all(&self.library.root); + winutil::open_folder(&self.library.root); + } + + /// Ends Jiffshot: the editor's edits are saved, the capture side stops, + /// then the application. The capture side's own Exit comes back here. + pub fn exit(&mut self) { + if std::mem::replace(&mut self.exiting, true) { + return; + } + self.autosave(); + self.stop_video(); + jiffshot_workflow::stop_engine(); + let _ = self.scope.shutdown(); + } + + /// The editor window was closed: Jiffshot keeps running in the tray. + pub fn hidden(&mut self) { + self.autosave(); + self.stop_video(); + } + + /// Shows a notice from the capture side as a toast (the `toast` signal). + pub fn toast(&mut self, title: &str, text: &str, problem: bool) { + self.toasts += 1; + let value = format!("{}\u{1f}{title}\u{1f}{text}\u{1f}{}", self.toasts, problem as u8); + self.put_str("Toast", &value, |k, v| k.set_toast(v)); + } + + pub fn show_capture_window(&mut self) { + crate::capture_window::show(&self.scope); + } + + /// Shows Grab Text's result in its window (the `grab_text` signal). + pub fn show_grab_text(&mut self, text: &str) { + self.toasts += 1; + let value = format!("{}\u{1f}{text}", self.toasts); + self.put_str("GrabText", &value, |k, v| k.set_grab_text(v)); + } + + /// The selection overlay to show, or empty (the `overlay` signal). + pub fn set_overlay(&mut self, json: &str) { + self.put_str("Overlay", json, |k, v| k.set_overlay(v)); + } + + /// Screen Draw to show, or empty (the `screen_draw` signal). + pub fn set_screen_draw(&mut self, json: &str) { + self.put_str("ScreenDraw", json, |k, v| k.set_screen_draw(v)); + } + + /// Opens the Preferences dialog on `tab` (the `prefs` signal). + pub fn open_prefs(&mut self, tab: &str) { + self.toasts += 1; + let value = format!("{}\u{1f}{}", self.toasts, crate::prefs::dialog(self, tab)); + self.put_str("Prefs", &value, |k, v| k.set_prefs(v)); + } + + /// The capture side's panels and outlines (the `hud` signal). + pub fn set_hud(&mut self, json: &str) { + self.put_str("Hud", json, |k, v| k.set_hud(v)); + } + + /// The Capture Widget follows the capture side's setting. + pub fn set_widget(&mut self, on: bool) { + self.put_flag("Widget", on, |k, v| k.set_widget(v)); + } + + /// Shows the editor window and brings it forward. + pub fn show_editor(&mut self) { + if let Some(window) = self.scope.main_window() { + let _ = window.show(); + } + winutil::bring_to_front(); + self.refresh_library(); + } + + pub fn save_quick_style(&mut self) { + let Some((tool, style)) = with_editor(|e| (e.selected().map(ed::tool_for).unwrap_or(e.tool()), e.current_style())) else { return }; + self.settings.add_quick_style(tool.name(), style); + self.settings.save(); + self.last_kind = Some("refresh".into()); + self.set_status(format!("Saved a quick style for {}", tool.name())); + } + + fn publish_quick_styles(&mut self, kind: &str) { + let _t = crate::trace::span("publish_quick_styles"); + let tool = Tool::from_name(kind).unwrap_or(Tool::Select); + let styles = self.settings.quick_styles(tool); + // Favorites first, each group in the list's order. + let mut order: Vec<(usize, Style, bool)> = styles.into_iter().enumerate().map(|(i, s)| { let f = self.settings.is_favorite(tool, &s); (i, s, f) }).collect(); + order.sort_by_key(|(_, _, favorite)| !favorite); + let Some(sink) = &self.sink else { return }; + let _ = sink.clear_quick_styles(); + for (index, style, favorite) in order { + let _ = sink.add_quick_styles(QuickStyle { index, tool, style, favorite, sink: None }); + } + } + + /// "up:Tool", "down:Tool", "hide:Tool", "show:Tool" or "reset"; the + /// toolbar keeps at least one tool. `all` is every tool in the usual + /// order, as the window lists them. + fn toolbar(&mut self, arg: &str) { + const ALL: &[&str] = &[ + "Select", "Arrow", "Line", "Shape", "Text", "Callout", "Step", "Pen", "Highlight", "Blur", "Magnify", "Spotlight", "Stamp", "Fill", + "Crop", "Cut Out", "Selection", "Magic Wand", "Eraser", "Eyedropper", "Smart Move", "Text Replace", + ]; + let mut order: Vec = if self.settings.toolbar.is_empty() { ALL.iter().map(|s| s.to_string()).collect() } else { self.settings.toolbar.clone() }; + let (verb, name) = arg.split_once(':').unwrap_or((arg, "")); + let at = order.iter().position(|t| t == name); + match (verb, at) { + ("reset", _) => order.clear(), + ("up", Some(i)) if i > 0 => order.swap(i - 1, i), + ("down", Some(i)) if i + 1 < order.len() => order.swap(i, i + 1), + ("hide", Some(i)) if order.len() > 1 => { + order.remove(i); + self.set_status(format!("{name} hidden; View ▸ Show all tools brings it back")); + } + ("show", None) if ALL.contains(&name) => order.push(name.to_owned()), + _ => return, + } + self.settings.toolbar = order; + self.settings.save(); + let joined = self.settings.toolbar.join(","); + self.put_str("ToolOrder", &joined, |k, v| k.set_tool_order(v)); + } + + /// Deletes, moves or (un)favorites the quick style at `index`. + pub fn edit_quick_style(&mut self, tool: Tool, index: usize, style: &Style, what: &str) { + match what { + "favorite" => self.settings.toggle_favorite(tool, style), + "delete" => self.settings.edit_styles(tool, |l| { + if index < l.len() { + l.remove(index); + } + }), + "earlier" if index > 0 => self.settings.edit_styles(tool, |l| { + if index < l.len() { + l.swap(index - 1, index); + } + }), + "later" => self.settings.edit_styles(tool, |l| { + if index + 1 < l.len() { + l.swap(index, index + 1); + } + }), + _ => return, + } + self.settings.save(); + self.publish_quick_styles(tool.name()); + } + + /// The current tool's (or selection's) quick styles from a theme, or + /// back to the built-in ones. + fn style_theme(&mut self, name: &str) { + let Some(tool) = with_editor(|e| e.selected().map(ed::tool_for).unwrap_or(e.tool())) else { return }; + if name == "reset" { + self.settings.reset_styles(tool); + self.set_status(format!("{} quick styles reset", tool.name())); + } else { + let t = combine::theme(name); + let styles = crate::settings::Settings::themed_styles(tool, &[t.accent, t.ink, t.muted, t.frame]); + self.settings.edit_styles(tool, |l| *l = styles); + self.set_status(format!("{} quick styles in the {} theme", tool.name(), t.name)); + } + self.settings.save(); + self.publish_quick_styles(tool.name()); + } + + pub fn apply_quick_style(&mut self, tool: Tool, style: Style) { + with_editor(|e| { + let font = e.current_style().font; + if e.selected().is_none() && e.tool() != tool { + e.set_tool(tool); + } + let apply = style.clone(); + e.update_style("quickstyle", move |s| { + *s = Style { font: font.clone(), ..apply.clone() }; + }); + e.seal(); + }); + } + + pub fn document_op(&mut self, label: &str, op: impl FnOnce(&mut Document) -> bool) { + let done = with_editor(|e| e.edit_document(label, op)).unwrap_or(false); + if done { + self.zoom("fit"); + } + } + + pub fn effect(&mut self, name: &str) { + let style = with_editor(|e| e.current_style()).unwrap_or_default(); + if name == "Watermark" { + return self.pick_open("Choose a watermark image", false, true, |app, paths| { + if let Some(image) = paths.first().and_then(|p| io::load_image(p).ok()) { + let effect = effects::Effect::Watermark { image: Arc::new(image), opacity: 0.35 }; + app.document_op("Watermark", |d| effects::apply(d, effect)); + app.set_status("Watermark added"); + } + }); + } + if name == "Spotlight & magnify" { + with_editor(|e| e.spotlight_magnify(1.6)); + self.bump_frame(); + return self.set_status("Spotlight & magnify: the selected area (or the middle) is enlarged; draw a rectangle first to choose it."); + } + if name == "Capture info" { + let text = self.capture_info(); + let effect = effects::Effect::CaptureInfo { text }; + self.document_op(name, |d| effects::apply(d, effect)); + return; + } + match effects::by_name_for(name, style.stroke, style.fill) { + Some(effect) => { + let started = std::time::Instant::now(); + self.document_op(name, |d| effects::apply(d, effect)); + self.set_status(format!("{name} applied in {:.0} ms", started.elapsed().as_secs_f64() * 1000.0)); + } + None => self.set_status(format!("Unknown effect {name}")), + } + } + + /// When and where the capture was taken, from its sidecar file. + fn capture_info(&self) -> String { + let meta = self + .source + .as_ref() + .and_then(|s| std::fs::read(s.with_extension("json")).ok()) + .and_then(|b| serde_json::from_slice::(&b).ok()); + let mut parts = Vec::new(); + if let Some(m) = &meta { + if let Some(t) = m["captured_at"].as_str() { + parts.push(t.replace('T', " ")); + } + if let Some(t) = m["window_title"].as_str() { + parts.push(t.to_owned()); + } + if let Some(p) = m["process"].as_str() { + parts.push(p.to_owned()); + } + } + if parts.is_empty() { + parts.push(jiffshot_capture::library::timestamp().replace('T', " ")); + } + parts.push("Windows".to_owned()); + parts.join(" · ") + } + + pub fn resize(&mut self, factor: f32) { + self.document_op("Resize", |d| { + let (w, h) = d.size(); + let (nw, nh) = (((w as f32 * factor).round() as u32).max(1), ((h as f32 * factor).round() as u32).max(1)); + if nw > 16384 || nh > 16384 { + return false; + } + d.scale(nw, nh); + true + }); + } + + pub fn set_tool(&mut self, tool: Tool) { + with_editor(|e| { + e.select(None); + e.set_tool(tool); + }); + let hint = match tool { + Tool::Crop => "Drag to crop the image.", + Tool::CutOut => "Drag across the part to remove; the rest closes the gap.", + Tool::Selection => "Drag to select part of the image, then move it.", + Tool::SmartMove => "Drag around an object to lift it off the background.", + Tool::TextReplace => "Drag around text to make it editable.", + Tool::Fill => "Click an area to fill it.", + Tool::Eyedropper => "Click to pick a color.", + _ => "", + }; + if !hint.is_empty() { + self.set_status(hint); + } + } + + /// `Run` command: actions from toolbar buttons and panel lists. + pub fn run(&mut self, action: &str) { + let (verb, arg) = action.split_once(':').unwrap_or((action, "")); + match verb { + "tool" => { + if let Some(t) = Tool::from_name(arg) { + self.set_tool(t); + } + } + "effect" => self.effect(arg), + "template" => self.template(arg), + "zoom" => self.zoom(arg), + "open" => self.open_path(Path::new(arg)), + "library" => self.refresh_library(), + "trim" => self.video_action(arg), + "finish" => { + with_editor(|e| e.finish_polygon()); + } + "add-stamp" => self.add_stamp(), + "drop" => self.drop_files(arg), + "style-theme" => self.style_theme(arg), + "toolbar" => self.toolbar(arg), + "zone-fit" => { + with_editor(|e| e.set_zone_fit(arg == "1")); + } + "zone-swap" => { + if !with_editor(|e| e.swap_zones()).unwrap_or(false) { + self.set_status("Shift+click two drop zones, then Swap."); + } + } + "tail" => { + if !with_editor(|e| e.callout_tail(arg != "remove")).unwrap_or(false) { + self.set_status("Select a callout first."); + } + } + "zone-empty" => { + with_editor(|e| e.empty_zone()); + } + "zone-pick" => self.pick_zone_image(), + "share" => self.share(arg), + "hidden" => self.hidden(), + // The Preferences dialog: open it on a tab; apply what it sends. + "prefs" => self.open_prefs(if arg.is_empty() { "capture" } else { arg }), + "prefs-apply" => crate::prefs::apply(self, arg), + // A capture panel's button ("ID:INDEX"; no index when closed), or + // a toolbar's action ("ID:act:NAME"). + "hud" => { + let mut parts = arg.splitn(3, ':'); + if let Some(id) = parts.next().and_then(|id| id.parse().ok()) { + match (parts.next(), parts.next()) { + (Some("act"), Some(action)) => jiffshot_workflow::panel_action(id, action), + (index, _) => jiffshot_workflow::answer_panel(id, index.and_then(|i| i.parse().ok())), + } + } + } + // The Capture Widget's buttons. + "widget" => { + let mode = match arg { + "video" => jiffshot_workflow::MODE_VIDEO, + "scrolling" => jiffshot_workflow::MODE_SCROLLING, + "text" => jiffshot_workflow::MODE_TEXT, + _ => jiffshot_workflow::MODE_IMAGE, + }; + jiffshot_workflow::request_capture(mode, 1); + } + "resize" | "trim-image" => { + let n: Vec = arg.split(',').filter_map(|v| v.trim().parse().ok()).collect(); + match (verb, n.as_slice()) { + ("resize", &[w, h]) => self.resize_to(w, h), + ("trim-image", &[l, t, r, b]) => self.trim(l, t, r, b), + _ => {} + } + } + // Editor Preferences. + "theme" if matches!(arg, "Dark" | "Light" | "System") => { + self.settings.set_theme(arg); + } + "export-format" => { + self.settings.export_format = Some(arg.to_ascii_lowercase()).filter(|f| !f.is_empty()); + self.settings.save(); + } + "ftp" => { + self.settings.ftp = Some(arg.trim().to_owned()).filter(|u| !u.is_empty()); + self.settings.save(); + } + "program-remove" => { + self.settings.programs.retain(|(n, _)| n != arg); + self.settings.save(); + self.publish_shares(); + } + "library-folder" if !arg.is_empty() => { + let root = PathBuf::from(arg); + self.settings.set_library(root.clone()); + self.library = Library::new(root); + self.show_library(); + self.set_status(format!("Library: {arg}")); + } + "align" => { + with_editor(|e| e.align_selected(arg)); + } + "deselect" => { + with_editor(|e| e.select(None)); + } + "export-to" => { + let doc = with_editor(|e| e.document().clone()); + if let Some(doc) = doc { + match io::export(&doc, Path::new(arg)) { + Ok(()) => self.set_status(format!("Exported {arg}")), + Err(error) => self.set_status(format!("Export failed: {error}")), + } + } + } + _ => self.set_status(format!("Unknown action {action}")), + } + } + + // ---- property edits from the panel --------------------------------- + + pub fn style_edit(&mut self, property: &'static str, change: impl Fn(&mut Style)) { + with_editor(|e| e.update_style(property, change)); + } + + pub fn set_text(&mut self, value: &str) { + with_editor(|e| e.set_selected_text(value)); + } + + pub fn set_variant(&mut self, value: &str) { + if value.is_empty() { + return; + } + with_editor(|e| e.set_variant(value)); + } + + pub fn set_amount(&mut self, value: f64) { + with_editor(|e| { + if e.selection().is_some() { + e.set_selected_amount(value as f32); + } else { + let t = e.tool(); + e.set_tool_amount(t, value as f32); + } + }); + } + + pub fn set_rotation(&mut self, value: f64) { + with_editor(|e| e.set_selected_rotation(value as f32)); + } + + pub fn document_expand(&mut self) { + if with_editor(|e| e.expand_to_fit()).unwrap_or(false) { + self.zoom("fit"); + } else { + self.set_status("Every object already fits on the canvas"); + } + } + + // ---- sharing ----------------------------------------------------------- + + /// The flattened image as a temporary file named after the capture. + fn temp_export(&self, ext: &str) -> std::result::Result { + let doc = with_editor(|e| e.document().clone()).ok_or("no document")?; + let dir = std::env::temp_dir().join("Jiffshot share"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let path = dir.join(format!("{}.{ext}", self.stem())); + io::export(&doc, &path)?; + Ok(path) + } + + pub fn share(&mut self, to: &str) { + use crate::share; + let office = |exe: &'static str| move |p: &Path| share::open_in(exe, p); + let result: std::result::Result = (|| match to { + "Open with…" => share::open_with(&self.temp_export("png")?).map(|_| "Choose a program".into()), + "Email" => share::email(&self.temp_export("png")?, &self.stem()).map(|_| "Opened a new message".into()), + "Word" => office("winword.exe")(&self.temp_export("png")?).map(|_| "Opened in Word".into()), + "PowerPoint" => office("powerpnt.exe")(&self.temp_export("png")?).map(|_| "Opened in PowerPoint".into()), + "Excel" => office("excel.exe")(&self.temp_export("png")?).map(|_| "Opened in Excel".into()), + "OneNote" => office("onenote.exe")(&self.temp_export("png")?).map(|_| "Opened in OneNote".into()), + "Paint" => office("mspaint.exe")(&self.temp_export("png")?).map(|_| "Opened in Paint".into()), + "Print" => share::print(&self.temp_export("png")?).map(|_| "Sent to the printer dialog".into()), + "FTP" => match self.settings.ftp.clone() { + Some(url) => share::ftp(&url, &self.temp_export("png")?).map(|at| format!("Uploaded to {at}")), + None => Err("Set \"ftp\": \"ftp://user@host/folder\" in %APPDATA%\\Jiffshot\\editor.json, and the password as the \"Jiffshot FTP\" credential (cmdkey /generic:\"Jiffshot FTP\" /user:USER /pass)".into()), + }, + "Folder…" => { + let file = self.temp_export("png")?; + let Some(window) = self.window() else { return Err("no window".into()) }; + let options = rustolonia::storage::FolderPickerOptions::new().title("Share to folder"); + let op = self.scope.open_folder_picker(&window, &options).map_err(|e| e.to_string())?; + let _ = self.scope.spawn(async move { + if let Ok(PickerOutcome::Selected(items)) = op.await { + if let Some(dir) = items.first().and_then(|i| i.local_path().map(Path::to_path_buf)) { + let result = share::to_folder(&file, &dir); + post(move |app| match result { + Ok(p) => app.set_status(format!("Copied to {}", p.display())), + Err(e) => app.set_status(format!("Copy failed: {e}")), + }); + } + } + }); + Ok("Choose a folder".into()) + } + "Add program…" => { + self.pick_program(); + Ok("Choose a program".into()) + } + name => match self.settings.programs.iter().find(|(n, _)| n == name).map(|(_, p)| p.clone()) { + Some(exe) => share::open_in(&exe.to_string_lossy(), &self.temp_export("png")?).map(|_| format!("Opened in {name}")), + None => Err(format!("Unknown destination {name}")), + }, + })(); + match result { + Ok(note) => self.set_status(note), + Err(error) => self.set_status(format!("Share failed: {error}")), + } + } + + /// Asks for one or more files of the given extensions. + pub(crate) fn pick_files(&mut self, title: &str, multiple: bool, label: &str, exts: &[&str], then: impl FnOnce(&mut App, Vec) + Send + 'static) { + let Some(window) = self.window() else { return }; + let mut filter = FileTypeFilter::new(label); + for e in exts { + filter = filter.with_extension(e); + } + let options = OpenFilePickerOptions::new().title(title).allow_multiple(multiple).start_in(WellKnownFolder::Videos).file_type(filter); + let op = match self.scope.open_file_picker(&window, &options) { + Ok(op) => op, + Err(error) => return self.set_status(format!("File picker failed: {error}")), + }; + let _ = self.scope.spawn(async move { + if let Ok(PickerOutcome::Selected(items)) = op.await { + let paths: Vec = items.iter().filter_map(|i| i.local_path().map(Path::to_path_buf)).collect(); + post(move |app| then(app, paths)); + } + }); + } + + fn pick_program(&mut self) { + let Some(window) = self.window() else { return }; + let options = OpenFilePickerOptions::new().title("Add a program to share to").file_type(FileTypeFilter::new("Programs").with_extension("exe")); + let Ok(op) = self.scope.open_file_picker(&window, &options) else { return }; + let _ = self.scope.spawn(async move { + if let Ok(PickerOutcome::Selected(items)) = op.await { + if let Some(exe) = items.first().and_then(|i| i.local_path().map(Path::to_path_buf)) { + post(move |app| { + let name = exe.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + app.settings.programs.retain(|(n, _)| *n != name); + app.settings.programs.push((name.clone(), exe)); + app.settings.save(); + app.publish_shares(); + app.set_status(format!("{name} added to Share")); + }); + } + } + }); + } + + // ---- custom stamps --------------------------------------------------- + + fn stamps_dir() -> Option { + jiffshot_capture::paths::config_dir().map(|d| d.join("Stamps")) + } + + /// Loads the stamps in `%APPDATA%\Jiffshot\Stamps` into the Stamp tool. + pub fn load_stamps(&mut self) { + let Some(dir) = Self::stamps_dir() else { return }; + let mut stamps = Vec::new(); + for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() { + let path = entry.path(); + let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).unwrap_or_default(); + if !matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp") { + continue; + } + if let (Ok(pm), Some(stem)) = (io::load_image(&path), path.file_stem()) { + stamps.push((stem.to_string_lossy().into_owned(), Arc::new(pm))); + } + } + stamps.sort_by(|a, b| a.0.cmp(&b.0)); + with_editor(|e| e.custom_stamps = stamps); + self.last_kind = Some("refresh".into()); + } + + /// Copies image files into the stamps folder. + pub fn add_stamp(&mut self) { + self.pick_open("Add stamps", true, true, |app, paths| { + let Some(dir) = App::stamps_dir() else { return }; + let _ = std::fs::create_dir_all(&dir); + let mut n = 0; + for p in &paths { + if let Some(name) = p.file_name() { + if std::fs::copy(p, dir.join(name)).is_ok() { + n += 1; + } + } + } + app.load_stamps(); + app.set_status(format!("Added {n} stamp(s); pick them in the Stamp tool's type list")); + }); + } + + // ---- selection and image commands ------------------------------------- + + pub fn publish_narration(&mut self) { + let on = self.narration.is_some(); + self.put_flag("Narrating", on, |k, v| k.set_narrating(v)); + } + + /// Changes a saved video choice and applies it. + pub fn video_prefs(&mut self, change: impl FnOnce(&mut crate::settings::VideoPrefs)) { + change(&mut self.settings.video); + self.settings.save(); + crate::video::apply_volume(self); + } + + /// Dark → Light → System (follows Windows) → Dark. + pub fn cycle_theme(&mut self) { + let next = match self.settings.theme.as_deref().unwrap_or("Dark") { + "Dark" => "Light", + "Light" => "System", + _ => "Dark", + }; + self.settings.set_theme(next); + self.set_status(format!("Theme: {next}")); + } + + pub fn set_background_fill(&mut self, value: &str) { + with_editor(|e| e.background_fill = jiffshot_doc::editor::BackgroundFill::from_name(value)); + } + + pub fn smart_redact(&mut self) { + let Some(flat) = with_editor(|e| e.document().flatten()) else { return }; + let lines = match crate::ocr::recognize(&flat) { + Ok(lines) => lines, + Err(error) => return self.set_status(format!("Text recognition failed: {error}")), + }; + let found = jiffshot_doc::redact::areas(&lines); + if found.is_empty() { + return self.set_status("Smart Redact found nothing sensitive"); + } + let mut kinds: Vec<&str> = found.iter().map(|(_, k)| k.name()).collect(); + kinds.sort(); + kinds.dedup(); + let count = found.len(); + with_editor(|e| { + let style = Style { fill: e.tool_style(Tool::Blur).fill.with_alpha(255), width: 0.0, ..Style::default() }; + let mut ids = Vec::new(); + e.edit_document("Smart Redact", |d| { + for (area, _) in &found { + let rect = area.to_box().inflate(3.0); + ids.push(d.add(Kind::Blur { rect, mode: jiffshot_doc::object::BlurMode::Redact, amount: 10.0 }, style.clone())); + } + true + }); + e.set_selection(ids); + }); + self.set_status(format!("Smart Redact covered {count} item(s): {}. Delete a box to reveal what is under it.", kinds.join(", "))); + } + + pub fn new_from_clipboard(&mut self) { + use jiffshot_capture::clipboard::Pasted; + match jiffshot_capture::clipboard::read(winutil::main_hwnd()) { + Ok(Some(Pasted::Image { width, height, rgba })) => { + if let Some(pm) = image::RgbaImage::from_raw(width, height, rgba).and_then(|i| io::from_rgba_image(&i)) { + self.load(Document::new(pm), None, None); + self.set_status(format!("New image from the clipboard ({width} × {height})")); + } + } + Ok(Some(Pasted::Files(files))) => match files.first() { + Some(f) => self.open_path(f), + None => self.set_status("The clipboard has no image"), + }, + _ => self.set_status("The clipboard has no image"), + } + } + + /// Prints the flattened image through the system's photo printing. + pub fn print(&mut self) { + let Some(doc) = with_editor(|e| e.document().clone()) else { return }; + let path = std::env::temp_dir().join(format!("{} (print).png", self.stem())); + match io::export(&doc, &path) { + Ok(()) if winutil::print_file(&path) => self.set_status("Sent to the printer dialog"), + Ok(()) => self.set_status("Windows has no print handler for PNG images"), + Err(error) => self.set_status(format!("Print failed: {error}")), + } + } + + /// Closes the document and shows the next capture in the library. + pub fn close_file(&mut self) { + self.autosave(); + let current = self.source.clone(); + let next = self.library_items.iter().map(|i| i.path.clone()).find(|p| Some(p) != current.as_ref()); + match next { + Some(p) => self.open_path(&p), + None => self.new_image(), + } + } + + /// Copies the current capture (with its edits) as a new library item. + pub fn duplicate_file(&mut self) { + let Some(doc) = with_editor(|e| e.document().clone()) else { return }; + let folder = self.source.as_ref().and_then(|s| s.parent().map(Path::to_path_buf)).unwrap_or_else(|| self.library.root.join("Copies")); + let mut path = folder.join(format!("{} copy.png", self.stem())); + let mut n = 2; + while path.exists() { + path = folder.join(format!("{} copy {n}.png", self.stem())); + n += 1; + } + let result = std::fs::create_dir_all(&folder) + .map_err(|e| e.to_string()) + .and_then(|_| io::write_atomic(&path, &io::encode(doc.background(), io::Format::Png, 0)?)) + .and_then(|_| io::save_document(&doc, &path.with_extension(EDITS_EXT))); + match result { + Ok(()) => { + self.refresh_library(); + self.open_path(&path); + self.set_status(format!("Duplicated as {}", path.display())); + } + Err(error) => self.set_status(format!("Duplicate failed: {error}")), + } + } + + /// Moves the current capture and its edits to the Recycle Bin. + pub fn delete_file(&mut self) { + let Some(src) = self.source.clone().filter(|s| s.starts_with(&self.library.root)) else { + return self.set_status("Only library captures can be deleted here"); + }; + with_editor(|e| e.mark_saved()); + let files: Vec = ["png", "mp4", EDITS_EXT, "json", "cursor.png", "cursor.json"].iter().map(|x| src.with_extension(x)).filter(|p| p.exists()).collect(); + if winutil::recycle(&files) { + self.source = None; + self.path = None; + self.refresh_library(); + self.close_file(); + self.set_status(format!("Moved {} to the Recycle Bin", src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())); + } else { + self.set_status("Could not move the capture to the Recycle Bin"); + } + } + + // ---- library --------------------------------------------------------- + + /// Reads the library from disk and shows it. + pub fn refresh_library(&mut self) { + self.library_entries = self.library.recent(2000).into_iter().map(library::Entry::read).collect(); + crate::tray::set_recent(self.library_entries.iter().take(10).map(|e| file_uri(&e.path)).collect()); + self.show_library(); + } + + /// Shows the library entries already read, searched, filtered and + /// sorted. + fn show_library(&mut self) { + let entries = std::mem::take(&mut self.library_entries); + self.show_entries(&entries); + self.library_entries = entries; + } + + fn show_entries(&mut self, entries: &[library::Entry]) { + let view = self.settings.library_view.clone(); + // The tray leaves out what was closed from it; the library view + // shows everything. + let closed: std::collections::HashSet<&PathBuf> = view.closed.iter().collect(); + let visible: Vec = entries.iter().filter(|e| view.expanded || !closed.contains(&e.path)).cloned().collect(); + let shown = library::select(&visible, &view.search, &view.filter, &view.sort, std::time::SystemTime::now()); + let limit = if view.expanded { 600 } else { 120 }; + self.library_selected.retain(|p| shown.iter().any(|e| &e.path == p)); + self.library_items = shown + .into_iter() + .take(limit) + .map(|e| { + let (current, selected) = (self.source.as_deref() == Some(e.path.as_path()), self.library_selected.contains(&e.path)); + library::Item::new(e, current, selected, view.thumb * 88.0 / 120.0) + }) + .collect(); + let filters = library::filters(entries); + if let Some(sink) = &self.sink { + let _ = sink.clear_library(); + for item in &self.library_items { + let _ = sink.add_library(item.clone()); + } + if self.shown_filters != filters { + // A new list clears the box's choice: show it again. + self.shown.strings.remove("LibraryFilter"); + self.shown.strings.remove("LibrarySort"); + let _ = sink.clear_library_filters(); + for f in &filters { + let _ = sink.add_library_filters(f); + } + if self.shown_filters.is_empty() { + let _ = sink.clear_library_sorts(); + for s in library::SORTS { + let _ = sink.add_library_sorts(*s); + } + } + } + } + self.shown_filters = filters; + let hidden = if view.expanded { 0 } else { closed.len() }; + let mut info = format!("{} of {} captures in {}", self.library_items.len(), entries.len(), self.library.root.display()); + if hidden > 0 { + info += &format!(" · {hidden} closed from the tray"); + } + self.put_str("LibraryInfo", &info, |k, v| k.set_library_info(v)); + self.put_str("LibrarySearch", &view.search, |k, v| k.set_library_search(v)); + self.put_str("LibraryFilter", &view.filter, |k, v| k.set_library_filter(v)); + self.put_str("LibrarySort", &view.sort, |k, v| k.set_library_sort(v)); + self.put_num("ThumbSize", view.thumb, |k, v| k.set_thumb_size(v)); + self.put_flag("LibraryExpanded", view.expanded, |k, v| k.set_library_expanded(v)); + let count = self.library_selected.len() as f64; + self.put_num("LibrarySelected", count, |k, v| k.set_library_selected(v as i64)); + self.mark_unsaved(); + library::make_thumbnails(self.library_items.clone()); + } + + /// Changes how the library is shown and refreshes it. + /// Changes which parts of the window show, and remembers it. + pub fn layout(&mut self, change: impl FnOnce(&mut crate::settings::Layout)) { + change(&mut self.settings.layout); + self.settings.save(); + self.publish_layout(); + } + + fn publish_layout(&mut self) { + let l = self.settings.layout.clone(); + self.put_flag("ShowSideBar", l.side_bar, |k, v| k.set_show_side_bar(v)); + self.put_flag("ShowEffects", l.effects, |k, v| k.set_show_effects(v)); + self.put_flag("ShowTray", l.tray, |k, v| k.set_show_tray(v)); + self.put_flag("ToolLabels", l.labels, |k, v| k.set_tool_labels(v)); + } + + pub fn library_view(&mut self, change: impl FnOnce(&mut crate::settings::LibraryView)) { + change(&mut self.settings.library_view); + self.settings.save(); + self.show_library(); + } + + /// The captures an item action applies to: the selection when the item + /// is part of it, else the item alone. + fn library_targets(&self, path: &Path) -> Vec { + if self.library_selected.contains(path) { + self.library_items.iter().filter(|i| self.library_selected.contains(&i.path)).map(|i| i.path.clone()).collect() + } else { + vec![path.to_path_buf()] + } + } + + /// Pin, close, delete, rename, tag, select or reveal library items. + pub fn library_action(&mut self, path: &Path, what: &str, value: Option) { + let targets = self.library_targets(path); + match what { + "select" => { + if !self.library_selected.remove(path) { + self.library_selected.insert(path.to_path_buf()); + } + let on = self.library_selected.contains(path); + if let Some(item) = self.library_items.iter().find(|i| i.path == path) { + item.set_selected(on); + } + let count = self.library_selected.len() as f64; + self.put_num("LibrarySelected", count, |k, v| k.set_library_selected(v as i64)); + return; + } + "reveal" => return winutil::reveal(path), + "pin" => { + let pin = !library::Entry::read(path.to_path_buf()).pinned(); + for t in &targets { + self.edit_meta(t, |m| m.pinned = pin); + } + self.set_status(format!("{} {}", if pin { "Pinned" } else { "Unpinned" }, count_of(targets.len()))); + } + "tags" => { + let tags = library::parse_tags(value.as_deref().unwrap_or("")); + for t in &targets { + self.edit_meta(t, |m| m.tags = tags.clone()); + } + self.set_status(if tags.is_empty() { "Tags removed".to_owned() } else { format!("Tagged {}: {}", count_of(targets.len()), tags.join(", ")) }); + } + "close" => { + for t in &targets { + if !self.settings.library_view.closed.contains(t) { + self.settings.library_view.closed.push(t.clone()); + } + } + self.settings.save(); + self.set_status(format!("Closed {} from the tray; they stay in the library", count_of(targets.len()))); + } + "delete" => { + let mut files = Vec::new(); + for t in &targets { + if Some(t) == self.source.as_ref() { + with_editor(|e| e.mark_saved()); + self.source = None; + self.path = None; + self.close_file(); + } + files.push(t.clone()); + files.extend(jiffshot_capture::library::companions(t).into_iter().filter(|c| c.exists())); + } + if winutil::recycle(&files) { + self.set_status(format!("Moved {} to the Recycle Bin", count_of(targets.len()))); + } else { + self.set_status("Could not move the capture to the Recycle Bin"); + } + } + "rename" => { + let name = value.unwrap_or_default(); + match jiffshot_capture::library::rename(path, &name) { + Ok(new) => { + let thumb = library::thumbnail_path(path); + let _ = std::fs::rename(&thumb, library::thumbnail_path(&new)); + if self.source.as_deref() == Some(path) { + self.source = Some(new.clone()); + if self.path.as_deref() == Some(path.with_extension(EDITS_EXT).as_path()) { + self.path = Some(new.with_extension(EDITS_EXT)); + } + } + self.set_status(format!("Renamed to {}", new.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())); + } + Err(error) => self.set_status(format!("Could not rename: {error}")), + } + } + _ => return, + } + self.refresh_library(); + } + + fn edit_meta(&mut self, path: &Path, change: impl FnOnce(&mut jiffshot_capture::library::CaptureMeta)) { + use jiffshot_capture::library::{read_meta, write_meta, CaptureKind, CaptureMeta}; + // Recordings and imported files get a sidecar when first tagged. + let mut meta = read_meta(path).unwrap_or_else(|| { + let (w, h) = io::load_image(path).map(|p| (p.width(), p.height())).unwrap_or((0, 0)); + CaptureMeta { + kind: if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("mp4")) { CaptureKind::Video } else { CaptureKind::Region }, + rect: jiffshot_capture::Rect::new(0, 0, w as i32, h as i32), + width: w, + height: h, + captured_at: String::new(), + window_title: None, + process: None, + cursor_included: false, + url: None, + tags: Vec::new(), + pinned: false, + } + }); + change(&mut meta); + if let Err(error) = write_meta(path, &meta) { + self.set_status(format!("Could not save the capture's details: {error}")); + } + } + + /// Images or videos picked in the library, combined into one. + pub fn combine_selected(&mut self) { + let picked: Vec = self.library_items.iter().filter(|i| self.library_selected.contains(&i.path)).map(|i| i.path.clone()).collect(); + if picked.len() < 2 { + return self.set_status("Ctrl+click two or more captures in the library first."); + } + let videos = picked.iter().filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("mp4"))).count(); + if videos == picked.len() { + return crate::video::combine_files(self, picked); + } + let images: Vec<_> = picked.iter().filter_map(|p| io::load_image(p).ok()).collect(); + if let Some(doc) = combine::combine(&images, combine::Arrangement::Grid, 24.0, Rgba::WHITE) { + self.load(doc, None, None); + with_editor(|e| e.checkpoint("Combine", None)); + self.set_status(format!("Combined {} images", images.len())); + } + } + + pub fn clear_library_selection(&mut self) { + self.library_selected.clear(); + for i in &self.library_items { + i.set_selected(false); + } + self.put_num("LibrarySelected", 0.0, |k, v| k.set_library_selected(v as i64)); + } + + /// Brings back everything closed from the tray. + pub fn show_closed(&mut self) { + let n = self.settings.library_view.closed.len(); + self.library_view(|v| v.closed.clear()); + self.set_status(format!("{} back in the tray", count_of(n))); + } + + /// Marks the open capture's tray item while it has unsaved edits. + fn mark_unsaved(&self) { + let dirty = with_editor(|e| e.is_modified()).unwrap_or(false); + for item in &self.library_items { + item.set_unsaved(dirty && self.source.as_deref() == Some(item.path.as_path())); + } + } + + fn mark_current(&mut self) { + let current = self.source.clone(); + for item in &self.library_items { + item.set_current(current.as_deref() == Some(item.path.as_path())); + } + } + + /// A new capture arrived from the capture side (or another launch). + pub fn incoming(&mut self, path: PathBuf) { + self.show_editor(); + self.open_path(&path); + self.refresh_library(); + } + + // ---- text recognition ------------------------------------------------- + + pub fn grab_text(&mut self) { + let Some(flat) = with_editor(|e| e.document().flatten()) else { return }; + match crate::ocr::recognize(&flat) { + Ok(lines) if !lines.is_empty() => { + let text = lines.iter().map(|l| l.text.as_str()).collect::>().join("\n"); + let _ = jiffshot_capture::clipboard::copy_text(&text, winutil::main_hwnd()); + let preview: String = text.chars().take(80).collect(); + self.set_status(format!("Copied {} line(s) of text: {preview}", lines.len())); + } + Ok(_) => self.set_status("No text found"), + Err(error) => self.set_status(format!("Text recognition failed: {error}")), + } + } + + pub fn simplify(&mut self) { + let Some(flat) = with_editor(|e| e.document().flatten()) else { return }; + let lines = crate::ocr::recognize(&flat).unwrap_or_default(); + let mut bars = 0; + self.document_op("Simplify", |d| { + d.flatten_objects(); + bars = jiffshot_doc::smart::simplify(d, &lines, 12); + true + }); + self.set_status(format!("Simplified: {bars} text line(s) replaced by editable bars")); + } + + // ---- video (see video.rs) -------------------------------------------- + + pub(crate) fn stop_video(&mut self) { + if let Some(v) = self.video.take() { + v.stop(); + } + } + + fn open_video(&mut self, path: &Path) { + self.autosave(); + self.stop_video(); + match crate::video::Session::open(path) { + Ok(mut session) => { + let frame = session.frame_at(0.0); + let duration = session.duration(); + let info = session.info(); + self.video = Some(session); + crate::video::apply_volume(self); + if let Some(pm) = frame { + with_editor(|e| e.load(Document::new(pm))); + } + self.path = None; + self.source = Some(path.to_path_buf()); + if let Some(sink) = &self.sink { + let _ = sink.set_video_duration(duration); + let _ = sink.set_video_position(0.0); + let _ = sink.set_trim_start(0.0); + let _ = sink.set_trim_end(duration); + let _ = sink.set_video_info(&info); + } + self.zoom("fit"); + self.bump_frame(); + self.mark_current(); + self.set_status(format!("Opened video {}", path.display())); + } + Err(error) => self.set_status(format!("Could not open video {}: {error}", path.display())), + } + } + + pub fn video_action(&mut self, what: &str) { + crate::video::action(self, what); + } +} + +/// A quick style shown in the panel; Apply puts it on the selection (or the +/// tool). +pub struct QuickStyle { + /// Position in the tool's list. + index: usize, + tool: Tool, + style: Style, + favorite: bool, + sink: Option, +} + +impl QuickStyle { + fn edit(&self, what: &'static str) { + let (tool, index, style) = (self.tool, self.index, self.style.clone()); + post(move |app| app.edit_quick_style(tool, index, &style, what)); + } +} + +impl QuickStyleViewModel for QuickStyle { + fn attach(&mut self, sink: QuickStyleViewModelSink) -> Result<()> { + let s = &self.style; + sink.set_name(format!("{} {}", self.tool.name(), self.index + 1))?; + sink.set_stroke(s.stroke.to_hex())?; + sink.set_fill(if s.multiply { s.fill.with_alpha(255).to_hex() } else { s.fill.to_hex() })?; + sink.set_text_color(s.text_color.to_hex())?; + sink.set_width(s.width as f64)?; + sink.set_is_favorite(self.favorite)?; + self.sink = Some(sink); + Ok(()) + } + + fn detach(&mut self) -> Result<()> { + self.sink = None; + Ok(()) + } + + fn apply(&mut self) -> Result<()> { + let (tool, style) = (self.tool, self.style.clone()); + // Runs after this call returns, outside the nested model's lock. + post(move |app| app.apply_quick_style(tool, style)); + Ok(()) + } + + fn delete(&mut self) -> Result<()> { + self.edit("delete"); + Ok(()) + } + + fn move_earlier(&mut self) -> Result<()> { + self.edit("earlier"); + Ok(()) + } + + fn move_later(&mut self) -> Result<()> { + self.edit("later"); + Ok(()) + } + + fn toggle_favorite(&mut self) -> Result<()> { + self.edit("favorite"); + Ok(()) + } +} + +/// Shared handle for the library item sinks, updated from the thumbnail +/// thread. +pub type ItemSink = Arc>>; + +/// A capture taken with "pointer as an object" has the pointer saved next to +/// it (`.cursor.png` and `.cursor.json`); it opens as a movable image. +fn add_cursor_object(doc: &mut jiffshot_doc::Document, path: &Path) { + let stem = path.with_extension(""); + let png = PathBuf::from(format!("{}.cursor.png", stem.display())); + let Ok(json) = std::fs::read_to_string(format!("{}.cursor.json", stem.display())) else { return }; + let Ok(at) = serde_json::from_str::(&json) else { return }; + let (Some(x), Some(y)) = (at["x"].as_f64(), at["y"].as_f64()) else { return }; + let Ok(image) = jiffshot_doc::Pixmap::load_png(&png) else { return }; + let rect = jiffshot_doc::Box2::from_xywh(x as f32, y as f32, image.width() as f32, image.height() as f32); + let kind = jiffshot_doc::object::Kind::Image { rect, image: jiffshot_doc::object::ImageRef::new("cursor".to_owned(), image) }; + doc.add(kind, jiffshot_doc::object::Style { width: 0.0, ..Default::default() }); +} + +/// "1 capture", "3 captures". +fn count_of(n: usize) -> String { + format!("{n} capture{}", if n == 1 { "" } else { "s" }) +} + +/// A path as the storage URI the Open recent menu shows. +fn file_uri(path: &Path) -> String { + let text = path.to_string_lossy().replace('\\', "/"); + let mut out = String::from("file:///"); + for c in text.chars() { + match c { + ' ' => out.push_str("%20"), + '%' => out.push_str("%25"), + '#' => out.push_str("%23"), + c => out.push(c), + } + } + out +} + +/// Back from a `file:///` URI (or a plain path). +fn path_of_uri(uri: &str) -> PathBuf { + let Some(rest) = uri.strip_prefix("file:///") else { return PathBuf::from(uri) }; + let bytes = rest.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(v) = u8::from_str_radix(&rest[i + 1..i + 3], 16) { + out.push(v); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + PathBuf::from(String::from_utf8_lossy(&out).replace('/', "\\")) +} + +/// The image without its edges of one solid color (the top-left pixel's), +/// or `None` when there is nothing to trim. +fn trim_bounds(image: &jiffshot_doc::Pixmap) -> Option { + let (w, h) = (image.width() as usize, image.height() as usize); + let px = image.pixels(); + let edge = px.first()?.demultiply(); + let same = |x: usize, y: usize| { + let c = px[y * w + x].demultiply(); + let d = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 8; + d(c.red(), edge.red()) && d(c.green(), edge.green()) && d(c.blue(), edge.blue()) && d(c.alpha(), edge.alpha()) + }; + let row = |y: usize| (0..w).all(|x| same(x, y)); + let col = |x: usize, y0: usize, y1: usize| (y0..y1).all(|y| same(x, y)); + let top = (0..h).find(|&y| !row(y))?; + let bottom = (0..h).rev().find(|&y| !row(y))? + 1; + let left = (0..w).find(|&x| !col(x, top, bottom))?; + let right = (0..w).rev().find(|&x| !col(x, top, bottom))? + 1; + let r = jiffshot_doc::IRect::new(left as i32, top as i32, (right - left) as i32, (bottom - top) as i32); + (r != jiffshot_doc::IRect::new(0, 0, w as i32, h as i32)).then_some(r) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_trim_finds_the_picture_inside_a_solid_border() { + let mut pm = jiffshot_doc::Pixmap::new(40, 30).unwrap(); + pm.fill(tiny_skia::Color::WHITE); + let mut paint = tiny_skia::Paint::default(); + paint.set_color_rgba8(200, 30, 30, 255); + pm.fill_rect(tiny_skia::Rect::from_xywh(5.0, 7.0, 20.0, 10.0).unwrap(), &paint, tiny_skia::Transform::identity(), None); + assert_eq!(trim_bounds(&pm), Some(jiffshot_doc::IRect::new(5, 7, 20, 10))); + pm.fill(tiny_skia::Color::WHITE); + assert_eq!(trim_bounds(&pm), None, "one color all over"); + } + + #[test] + fn recent_files_round_trip_through_uris() { + let path = Path::new(r"C:\Users\me\Pictures\Jiffshot\2026-10\a capture #1 100%.png"); + let uri = file_uri(path); + assert_eq!(uri, "file:///C:/Users/me/Pictures/Jiffshot/2026-10/a%20capture%20%231%20100%25.png"); + assert_eq!(path_of_uri(&uri), path); + } +} diff --git a/apps/jiffshot/app/src/canvas.rs b/apps/jiffshot/app/src/canvas.rs new file mode 100644 index 0000000..c51f104 --- /dev/null +++ b/apps/jiffshot/app/src/canvas.rs @@ -0,0 +1,175 @@ +//! The editor and the calls the canvas view makes on it. Rust owns the +//! document and the display raster (in [`EDITOR`]); after each call the view +//! ([`crate::ui::canvas_view`]) copies the damaged part of the raster to its +//! surface and draws the selection overlay from [`Overlay`]. + +use jiffshot_doc::editor::{Editor, Modifiers, EVENT_FOCUS_TEXT, EVENT_RESIZED}; +use jiffshot_doc::object::Handle; +use jiffshot_doc::{Box2, IRect, Pixmap, Pt}; +use parking_lot::Mutex; +use std::sync::atomic::{AtomicU32, Ordering}; + +pub const POINTER_PRESSED: i32 = 0; +pub const POINTER_MOVED: i32 = 1; +pub const POINTER_RELEASED: i32 = 2; + +/// Modifier bits of [`pointer`]. +pub const MOD_SHIFT: i32 = 1; +pub const MOD_CTRL: i32 = 2; +pub const MOD_ALT: i32 = 4; + +/// Damage flags: the view model should refresh, the whole frame must be +/// re-read (the size changed), the text box should take focus. +pub const FLAG_CHANGED: i32 = 1; +pub const FLAG_FULL: i32 = 2; +pub const FLAG_FOCUS_TEXT: i32 = 4; + +/// The only editor; `None` until the app has started. +pub static EDITOR: Mutex> = Mutex::new(None); + +/// Events taken from the editor during canvas calls, for the view model to +/// handle on its next sync. +pub static PENDING_EVENTS: AtomicU32 = AtomicU32::new(0); + +/// What a canvas call changed: the display rectangle to copy (empty when +/// nothing changed), the `FLAG_*` bits and the pointer cursor to show (a +/// `jiffshot_doc::editor::Cursor` code). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Damage { + pub rect: IRect, + pub flags: i32, + pub cursor: i32, +} + +impl Damage { + /// Adds `later` to this damage: the union of the rectangles and flags, + /// and the later cursor. + pub fn merge(&mut self, later: Damage) { + self.rect = self.rect.union(&later.rect); + self.flags |= later.flags; + self.cursor = later.cursor; + } +} + +/// What the view draws over the image, in document pixels. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Overlay { + /// The selected object's box, before `rotation` (degrees, about its + /// center). + pub selection: Option, + pub rotation: f32, + /// The marquee of crop-like tools or of a box selection. + pub marquee: Option, + /// The marquee selects objects: no crop shade or size label. + pub box_select: bool, + /// The selection's handles, or the canvas edge handles when nothing is + /// selected (see `canvas_handles`). + pub handles: Vec<(Handle, Pt)>, + /// `handles` are the canvas edge handles (drag to resize the canvas). + pub canvas_handles: bool, + /// Boxes of the other selected objects. + pub others: Vec, +} + +fn modifiers(bits: i32) -> Modifiers { + Modifiers { shift: bits & MOD_SHIFT != 0, ctrl: bits & MOD_CTRL != 0, alt: bits & MOD_ALT != 0 } +} + +/// Renders pending damage and reports it with the editor's new events. +fn damage_of(editor: &mut Editor) -> Damage { + let rect = editor.take_damage().unwrap_or_default(); + let events = editor.take_events(); + PENDING_EVENTS.fetch_or(events, Ordering::Relaxed); + let mut flags = 0; + if events != 0 { + flags |= FLAG_CHANGED; + } + if events & EVENT_RESIZED != 0 { + flags |= FLAG_FULL; + } + if events & EVENT_FOCUS_TEXT != 0 { + flags |= FLAG_FOCUS_TEXT; + } + Damage { rect, flags, cursor: 0 } +} + +/// Feeds one pointer event at (`x`, `y`) in document pixels: `kind` is a +/// `POINTER_*`, `mods` `MOD_*` bits and `tolerance` the hit distance in +/// document pixels. Returns the damage and the cursor for that point. +pub fn pointer(kind: i32, x: f64, y: f64, mods: i32, tolerance: f64) -> Damage { + let _t = crate::trace::span("pointer"); + let mut guard = EDITOR.lock(); + let Some(editor) = guard.as_mut() else { return Damage::default() }; + let p = Pt::new(x as f32, y as f32); + let m = modifiers(mods); + match kind { + POINTER_PRESSED => editor.pointer_down(p, m, tolerance as f32), + POINTER_MOVED => editor.pointer_move(p, m), + POINTER_RELEASED => editor.pointer_up(p, m), + _ => {} + } + let mut damage = damage_of(editor); + damage.cursor = editor.hover(p, tolerance as f32) as i32; + damage +} + +/// The cursor code for the pointer resting at (`x`, `y`) in document pixels. +pub fn hover(x: f64, y: f64, tolerance: f64) -> i32 { + let _t = crate::trace::span("hover"); + EDITOR.lock().as_ref().map_or(0, |e| e.hover(Pt::new(x as f32, y as f32), tolerance as f32) as i32) +} + +/// Renders whatever changed since the last call (edits made outside the +/// canvas) and reports it. +pub fn flush() -> Damage { + let _t = crate::trace::span("flush"); + EDITOR.lock().as_mut().map(damage_of).unwrap_or_default() +} + +/// Moves the selection by (`dx`, `dy`) document pixels. +pub fn nudge(dx: f64, dy: f64) -> Damage { + let _t = crate::trace::span("nudge"); + let mut guard = EDITOR.lock(); + let Some(editor) = guard.as_mut() else { return Damage::default() }; + editor.nudge(dx as f32, dy as f32); + damage_of(editor) +} + +/// Runs `f` on the display raster (premultiplied RGBA, tightly packed rows) +/// with the editor locked, so the copy never races an edit. +pub fn with_display(f: impl FnOnce(&Pixmap) -> R) -> Option { + let _t = crate::trace::span("with_display"); + EDITOR.lock().as_ref().map(|e| f(e.display())) +} + +/// The selection overlay as it is now. +pub fn overlay() -> Overlay { + let _t = crate::trace::span("overlay"); + let mut o = Overlay::default(); + let guard = EDITOR.lock(); + let Some(editor) = guard.as_ref() else { return o }; + if let Some(b) = editor.selection_box() { + o.selection = Some(b); + o.rotation = editor.selected().map_or(0.0, |s| if s.rotatable() { s.rotation } else { 0.0 }); + o.handles = editor.selection_handles(); + } + if let Some(m) = editor.marquee() { + o.marquee = Some(m); + // Box selection, not an area of the image: no shade or size. + o.box_select = editor.tool() == jiffshot_doc::Tool::Select; + } + if o.selection.is_none() { + let canvas = editor.canvas_handles(); + if !canvas.is_empty() { + o.handles = canvas; + o.canvas_handles = true; + } + } + o.others = editor.other_boxes(); + o +} + +/// Runs `f` on the editor (if started) without holding the lock afterwards. +pub fn with_editor(f: impl FnOnce(&mut Editor) -> R) -> Option { + EDITOR.lock().as_mut().map(f) +} diff --git a/apps/jiffshot/app/src/capture_window.rs b/apps/jiffshot/app/src/capture_window.rs new file mode 100644 index 0000000..cf57d5b --- /dev/null +++ b/apps/jiffshot/app/src/capture_window.rs @@ -0,0 +1,251 @@ +//! The Capture window's model. What the window shows and offers comes from +//! `jiffshot_workflow::panel` as JSON ([`View`], menus, option panels); the +//! window sends back actions, and every change goes to the capture side, +//! which owns the settings ([`capture::apply`]). +//! +//! Actions: `tab:N`, `switch:ID`, `menu:NAME`, `pick:ID`, `panel:ID`, +//! `apply:ID:[values]`, `capture`, `editor`, `closed`. + +use crate::app; +use crate::vm::{CaptureViewModel, CaptureViewModelSink}; +use jiffshot_workflow::panel::{self, Applied, Pick}; +use jiffshot_workflow::settings::Settings; +use jiffshot_workflow::{capture, TrayAction, TrayState}; +use rustolonia::{AppScope, Result}; +use std::cell::RefCell; + +struct State { + sink: Option, + /// The settings being edited: the capture side's, plus changes not yet + /// echoed back. + settings: Settings, + capture_key: String, + timelapse: bool, + window: Option, + serial: u64, +} + +thread_local! { + static STATE: RefCell = RefCell::new(State { + sink: None, + settings: Settings::default(), + capture_key: "PrtScn".into(), + timelapse: false, + window: None, + serial: 0, + }); +} + +fn with(f: impl FnOnce(&mut State) -> R) -> R { + STATE.with(|s| f(&mut s.borrow_mut())) +} + +/// Remembers the window mounted for this model (hidden until shown). +pub fn set_window(window: rustolonia::Window) { + with(|s| s.window = Some(window)); +} + +/// Shows the window with the capture side's current settings, in front. +pub fn show(scope: &AppScope) { + with(|s| s.settings = jiffshot_workflow::settings_snapshot()); + publish_view(); + let Some(window) = with(|s| s.window.clone()) else { return }; + let _ = window.show(); + if let Ok(hwnd) = scope.platform_handle(&window) { + crate::winutil::bring_forward(hwnd); + } +} + +fn hide() { + if let Some(window) = with(|s| s.window.clone()) { + let _ = window.hide(); + } +} + +/// The capture side changed something: show it. +pub fn refresh(state: &TrayState) { + let tab = with(|s| { + s.capture_key = state.capture_key.clone(); + s.timelapse = state.time_lapse; + s.settings.capture_window.tab + }); + let mut settings = jiffshot_workflow::settings_snapshot(); + // The tab is this window's; the capture side may not have it yet. + settings.capture_window.tab = tab; + with(|s| s.settings = settings); + publish_view(); +} + +fn publish_view() { + let (sink, json) = with(|s| { + let view = panel::view(&s.settings, &s.capture_key, s.timelapse); + (s.sink.clone(), serde_json::to_string(&view).unwrap_or_default()) + }); + if let Some(sink) = sink { + let _ = sink.set_view(json); + } +} + +/// Sends `json` as the next value of a "serial\x1fJSON" property. +fn send(set: impl Fn(&CaptureViewModelSink, String) -> Result<()>, json: String) { + let (sink, serial) = with(|s| { + s.serial += 1; + (s.sink.clone(), s.serial) + }); + if let Some(sink) = sink { + let _ = set(&sink, format!("{serial}\u{1f}{json}")); + } +} + +fn problem(text: &str) { + send(|k, v| k.set_problem(v), text.to_owned()); +} + +/// Saves the edited settings on the capture side and shows them. +fn changed() { + capture::apply(with(|s| s.settings.clone())); + publish_view(); +} + +fn set_presets(presets: Vec) { + with(|s| s.settings.presets = presets.clone()); + capture::set_presets(presets); + publish_view(); +} + +/// The capture hotkey as the capture side last published it. +pub fn capture_key() -> String { + with(|s| s.capture_key.clone()) +} + +fn open_panel(id: &str) { + // The capture preferences are a tab of the one Preferences dialog. + if id == "prefs" { + crate::app::post(|app| app.open_prefs("capture")); + return; + } + let (settings, key) = with(|s| (s.settings.clone(), s.capture_key.clone())); + match panel::panel(&settings, id, capture::runs_at_startup(), &key) { + Some(p) => send(|k, v| k.set_panel(v), serde_json::to_string(&p).unwrap_or_default()), + None => problem("Nothing to set here."), + } +} + +fn picked(id: &str) { + let result = with(|s| panel::pick(&mut s.settings, id)); + match result { + Pick::Nothing => {} + Pick::Changed => changed(), + Pick::Panel(id) => open_panel(&id), + Pick::RunPreset(i) => { + hide(); + capture::run_preset(i); + } + Pick::StepCapture => { + hide(); + capture::step_capture(); + } + Pick::Presets(presets) => set_presets(presets), + Pick::ImportPresets => app::post(|app| { + app.pick_files("Import presets", false, "Presets", &["json"], |_, paths| { + let Some(bytes) = paths.first().and_then(|p| std::fs::read(p).ok()) else { return }; + let current = with(|s| s.settings.presets.clone()); + match panel::import_presets(¤t, &bytes) { + Some(presets) => set_presets(presets), + None => problem("That file has no presets in it."), + } + }) + }), + Pick::ExportPresets => { + let presets = with(|s| s.settings.presets.clone()); + app::post(move |app| { + app.pick_save("Export presets", "Jiffshot presets.json", &[("Presets", "json")], move |_, path| { + let written = serde_json::to_vec_pretty(&presets).ok().and_then(|b| std::fs::write(&path, b).ok()); + if written.is_none() { + problem("Could not write the file."); + } + }) + }); + } + Pick::OpenEditor => app::post(|app| app.show_editor()), + Pick::OpenLibrary => capture::open_library(), + Pick::Close => hide(), + Pick::Exit => TrayAction::Exit.post(), + } +} + +fn applied(id: &str, values: &str) { + let values: Vec = serde_json::from_str(values).unwrap_or_default(); + let startup = capture::runs_at_startup(); + let result = with(|s| panel::apply(&mut s.settings, id, &values, startup)); + match result { + Applied::Changed => changed(), + Applied::MoveSchedule(at) => { + changed(); + capture::move_schedule(at); + } + Applied::Presets(presets) => set_presets(presets), + Applied::Startup(on) => { + changed(); + capture::set_run_at_startup(on); + } + Applied::Problem(text) => problem(&text), + } +} + +fn act(action: &str) { + let (verb, arg) = action.split_once(':').unwrap_or((action, "")); + match verb { + "tab" => picked(action), + "switch" => { + if let Some(switch) = panel::Switch::from_id(arg) { + with(|s| panel::flip(&mut s.settings, switch)); + changed(); + } + } + "menu" => { + let items = with(|s| panel::menu(&s.settings, arg)); + send(|k, v| k.set_menu(v), serde_json::to_string(&items).unwrap_or_default()); + } + "pick" => picked(arg), + "panel" => open_panel(arg), + "apply" => { + if let Some((id, values)) = arg.split_once(':') { + applied(id, values); + } + } + "capture" => { + hide(); + capture::start(with(|s| s.settings.clone())); + } + "editor" => app::post(|app| app.show_editor()), + _ => {} + } +} + +pub struct CaptureModel; + +impl CaptureViewModel for CaptureModel { + fn attach(&mut self, sink: CaptureViewModelSink) -> Result<()> { + with(|s| { + s.sink = Some(sink); + s.settings = jiffshot_workflow::settings_snapshot(); + }); + publish_view(); + Ok(()) + } + + fn detach(&mut self) -> Result<()> { + with(|s| s.sink = None); + Ok(()) + } + + fn set_action(&mut self, _value: String) -> Result<()> { + Ok(()) + } + + fn run(&mut self, value: String) -> Result<()> { + act(&value); + Ok(()) + } +} diff --git a/apps/jiffshot/app/src/library.rs b/apps/jiffshot/app/src/library.rs new file mode 100644 index 0000000..4a1c1fb --- /dev/null +++ b/apps/jiffshot/app/src/library.rs @@ -0,0 +1,433 @@ +//! The library: recent captures with thumbnails made on a background +//! thread and cached under `%LOCALAPPDATA%\Jiffshot\thumbs`, shown in the +//! tray strip or the full library view, searched, filtered, sorted, pinned, +//! tagged, renamed, closed from the tray and deleted. + +use crate::app::{self, ItemSink}; +use crate::vm::{LibraryItemViewModel, LibraryItemViewModelSink}; +use rustolonia::Result; +use jiffshot_capture::library::CaptureMeta; +use jiffshot_doc::{io, raster, Document, Pixmap}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +const THUMB_HEIGHT: u32 = 120; + +/// What the library knows about one capture. +#[derive(Clone, Debug)] +pub struct Entry { + pub path: PathBuf, + pub meta: Option, + pub modified: SystemTime, +} + +impl Entry { + pub fn read(path: PathBuf) -> Entry { + let meta = jiffshot_capture::library::read_meta(&path); + let modified = modified(&path).unwrap_or(SystemTime::UNIX_EPOCH); + Entry { path, meta, modified } + } + + pub fn is_video(&self) -> bool { + self.path.extension().is_some_and(|e| e.eq_ignore_ascii_case("mp4")) + } + + pub fn pinned(&self) -> bool { + self.meta.as_ref().is_some_and(|m| m.pinned) + } + + pub fn tags(&self) -> &[String] { + self.meta.as_ref().map_or(&[], |m| m.tags.as_slice()) + } + + pub fn app(&self) -> Option<&str> { + self.meta.as_ref()?.process.as_deref() + } + + /// The web site's host, for browser captures. + pub fn site(&self) -> Option<&str> { + let url = self.meta.as_ref()?.url.as_deref()?; + let rest = url.split_once("://").map_or(url, |(_, r)| r); + rest.split(['/', '?', '#']).next().filter(|h| !h.is_empty()) + } + + fn name(&self) -> String { + self.path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() + } + + /// Everything a search matches: name, window title, app, address, tags. + fn haystack(&self) -> String { + let mut s = self.name(); + if let Some(m) = &self.meta { + for part in [m.window_title.as_deref(), m.process.as_deref(), m.url.as_deref()].into_iter().flatten() { + s.push(' '); + s.push_str(part); + } + for t in &m.tags { + s.push(' '); + s.push_str(t); + } + s.push(' '); + s.push_str(&m.captured_at); + } + s.to_lowercase() + } + + pub fn info(&self) -> String { + let mut parts = vec![self.name()]; + if let Some(m) = &self.meta { + parts.extend(m.window_title.clone()); + parts.extend(m.process.clone()); + parts.extend(m.url.clone()); + parts.push(m.captured_at.replace('T', " ")); + parts.push(format!("{} × {}", m.width, m.height)); + if !m.tags.is_empty() { + parts.push(format!("Tags: {}", m.tags.join(", "))); + } + } + parts.join("\n") + } +} + +/// The filters offered, from what the entries have: fixed ones, then +/// each app, web site and tag. +pub fn filters(entries: &[Entry]) -> Vec { + let mut out: Vec = ["All", "Images", "Videos", "Pinned", "Today", "Last 7 days", "Tagged"].iter().map(|s| s.to_string()).collect(); + let mut add = |prefix: &str, values: Vec<&str>| { + let mut v: Vec<&str> = values; + v.sort_unstable_by_key(|s| s.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + out.extend(v.into_iter().map(|x| format!("{prefix}: {x}"))); + }; + add("App", entries.iter().filter_map(Entry::app).collect()); + add("Site", entries.iter().filter_map(Entry::site).collect()); + add("Tag", entries.iter().flat_map(|e| e.tags().iter().map(String::as_str)).collect()); + out +} + +pub const SORTS: &[&str] = &["Newest", "Oldest", "Name", "App", "Size"]; + +/// The entries a search, filter and sort leave, pinned ones first. +pub fn select(entries: &[Entry], query: &str, filter: &str, sort: &str, now: SystemTime) -> Vec { + let words: Vec = query.split_whitespace().map(str::to_lowercase).collect(); + let age = |e: &Entry| now.duration_since(e.modified).unwrap_or_default(); + let mut out: Vec = entries + .iter() + .filter(|e| { + let hay = e.haystack(); + words.iter().all(|w| hay.contains(w.as_str())) + }) + .filter(|e| match filter { + "Images" => !e.is_video(), + "Videos" => e.is_video(), + "Pinned" => e.pinned(), + "Today" => age(e) < Duration::from_secs(24 * 3600), + "Last 7 days" => age(e) < Duration::from_secs(7 * 24 * 3600), + "Tagged" => !e.tags().is_empty(), + f => match f.split_once(": ") { + Some(("App", a)) => e.app().is_some_and(|x| x.eq_ignore_ascii_case(a)), + Some(("Site", s)) => e.site().is_some_and(|x| x.eq_ignore_ascii_case(s)), + Some(("Tag", t)) => e.tags().iter().any(|x| x.eq_ignore_ascii_case(t)), + _ => true, + }, + }) + .cloned() + .collect(); + let pixels = |e: &Entry| e.meta.as_ref().map_or(0, |m| m.width as u64 * m.height as u64); + match sort { + "Oldest" => out.sort_by_key(|e| e.modified), + "Name" => out.sort_by_key(|e| e.name().to_lowercase()), + "App" => out.sort_by(|a, b| a.app().unwrap_or("~").to_lowercase().cmp(&b.app().unwrap_or("~").to_lowercase()).then(b.modified.cmp(&a.modified))), + "Size" => out.sort_by_key(|e| std::cmp::Reverse(pixels(e))), + _ => out.sort_by_key(|e| std::cmp::Reverse(e.modified)), + } + // Stable: pinned first, in the chosen order. + out.sort_by_key(|e| !e.pinned()); + out +} + +/// "a, b ,, c" → ["a", "b", "c"]. +pub fn parse_tags(text: &str) -> Vec { + let mut tags: Vec = Vec::new(); + for t in text.split([',', ';']).map(str::trim).filter(|t| !t.is_empty()) { + if !tags.iter().any(|x| x.eq_ignore_ascii_case(t)) { + tags.push(t.to_owned()); + } + } + tags +} + +#[derive(Clone)] +pub struct Item { + pub entry: Entry, + pub path: PathBuf, + current: Arc>, + selected: Arc>, + unsaved: Arc>, + new_name: String, + tag_text: String, + thumb_height: f64, + sink: ItemSink, +} + +impl Item { + pub fn new(entry: Entry, current: bool, selected: bool, thumb_height: f64) -> Item { + let tags = entry.tags().join(", "); + Item { + path: entry.path.clone(), + new_name: entry.name(), + tag_text: tags, + entry, + current: Arc::new(Mutex::new(current)), + selected: Arc::new(Mutex::new(selected)), + unsaved: Arc::new(Mutex::new(false)), + thumb_height, + sink: Arc::new(Mutex::new(None)), + } + } + + fn sink(&self) -> Option { + // Never call a sink while holding the lock: another thread may be + // waiting on the UI thread with it. + self.sink.lock().unwrap().clone() + } + + pub fn set_current(&self, value: bool) { + *self.current.lock().unwrap() = value; + if let Some(sink) = self.sink() { + let _ = sink.set_is_current(value); + } + } + + pub fn set_selected(&self, value: bool) { + *self.selected.lock().unwrap() = value; + if let Some(sink) = self.sink() { + let _ = sink.set_is_selected(value); + } + } + + pub fn set_unsaved(&self, value: bool) { + let changed = std::mem::replace(&mut *self.unsaved.lock().unwrap(), value) != value; + if let (true, Some(sink)) = (changed, self.sink()) { + let _ = sink.set_unsaved(value); + } + } + + fn label(&self) -> String { + // "Capture 2026-10-28 141503" → "Oct 28 14:15"; renamed captures + // keep their name. + let stem = self.entry.name(); + let video = stem.starts_with("Recording "); + let parts: Vec<&str> = stem.trim_start_matches("Capture ").trim_start_matches("Recording ").split(' ').collect(); + if let [date, time, ..] = parts.as_slice() { + let d: Vec<&str> = date.split('-').collect(); + if d.len() == 3 && time.len() >= 4 && time[..4].bytes().all(|b| b.is_ascii_digit()) { + const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + if let Ok(m) = d[1].parse::() { + return format!("{}{} {} {}:{}", if video { "▶ " } else { "" }, MONTHS[(m.max(1) - 1).min(11)], d[2].trim_start_matches('0'), &time[..2], &time[2..4]); + } + } + } + stem + } + + fn act(&self, what: &'static str, value: Option) { + let path = self.path.clone(); + app::post(move |app| app.library_action(&path, what, value)); + } +} + +impl LibraryItemViewModel for Item { + fn attach(&mut self, sink: LibraryItemViewModelSink) -> Result<()> { + sink.set_path(self.path.to_string_lossy())?; + sink.set_label(self.label())?; + sink.set_is_current(*self.current.lock().unwrap())?; + sink.set_is_selected(*self.selected.lock().unwrap())?; + sink.set_unsaved(*self.unsaved.lock().unwrap())?; + sink.set_kind(if self.entry.is_video() { "Video" } else { "Image" })?; + sink.set_is_pinned(self.entry.pinned())?; + sink.set_tags(self.entry.tags().join(", "))?; + sink.set_info(self.entry.info())?; + sink.set_new_name(self.new_name.clone())?; + sink.set_tag_text(self.tag_text.clone())?; + sink.set_thumb_height(self.thumb_height)?; + let thumb = thumbnail_path(&self.path); + if thumb.exists() { + sink.set_thumbnail(thumb.to_string_lossy())?; + } + *self.sink.lock().unwrap() = Some(sink); + Ok(()) + } + + fn detach(&mut self) -> Result<()> { + *self.sink.lock().unwrap() = None; + Ok(()) + } + + fn open(&mut self) -> Result<()> { + let path = self.path.clone(); + app::post(move |app| app.open_path(&path)); + Ok(()) + } + + fn toggle_pin(&mut self) -> Result<()> { + self.act("pin", None); + Ok(()) + } + + fn close(&mut self) -> Result<()> { + self.act("close", None); + Ok(()) + } + + fn delete(&mut self) -> Result<()> { + self.act("delete", None); + Ok(()) + } + + fn rename(&mut self, value: String) -> Result<()> { + self.act("rename", Some(value)); + Ok(()) + } + + fn set_tags(&mut self, value: String) -> Result<()> { + self.act("tags", Some(value)); + Ok(()) + } + + fn toggle_select(&mut self) -> Result<()> { + self.act("select", None); + Ok(()) + } + + fn show_in_folder(&mut self) -> Result<()> { + self.act("reveal", None); + Ok(()) + } +} + +fn cache_dir() -> PathBuf { + jiffshot_capture::paths::local_dir().join("thumbs") +} + +/// Where the thumbnail for a capture lives. The name hashes the path, so +/// moving the library never shows stale thumbnails. +pub fn thumbnail_path(capture: &Path) -> PathBuf { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in capture.to_string_lossy().to_lowercase().bytes() { + h = (h ^ b as u64).wrapping_mul(0x100_0000_01b3); + } + cache_dir().join(format!("{h:016x}.png")) +} + +fn modified(p: &Path) -> Option { + std::fs::metadata(p).and_then(|m| m.modified()).ok() +} + +/// Whether the thumbnail is older than the capture or its saved edits. +fn stale(capture: &Path) -> bool { + let thumb = modified(&thumbnail_path(capture)); + let edits = jiffshot_capture::library::edits_of(capture); + let newest = [modified(capture), edits.as_deref().and_then(modified)].into_iter().flatten().max(); + match (thumb, newest) { + (Some(t), Some(n)) => t < n, + (None, _) => true, + _ => false, + } +} + +fn shrink(pm: &Pixmap) -> Option { + let h = THUMB_HEIGHT.min(pm.height()); + let w = ((pm.width() as f32 * h as f32 / pm.height() as f32).round() as u32).clamp(1, 400); + raster::resample(pm, w, h) +} + +/// Writes the thumbnail of an edited document for its capture. +pub fn write_thumbnail(doc: &Document, capture: &Path) { + if let Some(small) = shrink(&doc.flatten()) { + let _ = std::fs::create_dir_all(cache_dir()); + if let Ok(bytes) = io::encode(&small, io::Format::Png, 0) { + let _ = std::fs::write(thumbnail_path(capture), bytes); + } + } +} + +fn render_source(capture: &Path) -> Option { + if capture.extension().is_some_and(|e| e.eq_ignore_ascii_case("mp4")) { + return crate::video::thumbnail(capture); + } + if let Some(edits) = jiffshot_capture::library::edits_of(capture) { + if let Ok(doc) = io::load_document(&edits) { + return Some(doc.flatten()); + } + } + io::load_image(capture).ok() +} + +/// Makes missing or stale thumbnails off the UI thread and shows each one +/// as it is ready. +pub fn make_thumbnails(items: Vec) { + std::thread::spawn(move || { + let _ = std::fs::create_dir_all(cache_dir()); + for item in items { + if !stale(&item.path) { + continue; + } + let Some(small) = render_source(&item.path).as_ref().and_then(shrink) else { continue }; + let thumb = thumbnail_path(&item.path); + if io::encode(&small, io::Format::Png, 0).ok().and_then(|b| std::fs::write(&thumb, b).ok()).is_some() { + // Sinks dispatch to the UI thread themselves. + if let Some(sink) = item.sink() { + let _ = sink.set_thumbnail(thumb.to_string_lossy()); + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use jiffshot_capture::library::CaptureKind; + use jiffshot_capture::Rect; + + fn entry(name: &str, age_hours: u64, app: Option<&str>, url: Option<&str>, tags: &[&str], pinned: bool) -> Entry { + let meta = CaptureMeta { + kind: CaptureKind::Region, + rect: Rect::new(0, 0, 10, 10), + width: 10, + height: 10, + captured_at: "2026-10-28T10:00:00".into(), + window_title: Some(format!("{name} window")), + process: app.map(str::to_owned), + cursor_included: false, + url: url.map(str::to_owned), + tags: tags.iter().map(|t| t.to_string()).collect(), + pinned, + }; + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + Entry { path: PathBuf::from(format!("C:/lib/{name}.png")), meta: Some(meta), modified: now - Duration::from_secs(age_hours * 3600) } + } + + #[test] + fn search_filter_sort_and_pins() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + let all = vec![ + entry("alpha", 1, Some("chrome.exe"), Some("https://docs.example.com/a?b"), &["docs"], false), + entry("beta", 30, Some("code.exe"), None, &[], true), + entry("gamma", 200, Some("chrome.exe"), None, &["Docs", "release"], false), + ]; + let names = |v: Vec| v.iter().map(|e| e.name()).collect::>(); + assert_eq!(names(select(&all, "", "All", "Newest", now)), ["beta", "alpha", "gamma"], "pinned first"); + assert_eq!(names(select(&all, "", "Today", "Newest", now)), ["alpha"]); + assert_eq!(names(select(&all, "", "Last 7 days", "Oldest", now)), ["beta", "alpha"]); + assert_eq!(names(select(&all, "CHROME window", "All", "Name", now)), ["alpha", "gamma"]); + assert_eq!(names(select(&all, "", "Tag: docs", "Newest", now)), ["alpha", "gamma"]); + assert_eq!(names(select(&all, "", "Site: docs.example.com", "Newest", now)), ["alpha"]); + let f = filters(&all); + assert!(f.contains(&"App: chrome.exe".to_string()) && f.contains(&"App: code.exe".to_string())); + assert_eq!(f.iter().filter(|x| x.starts_with("Tag: ")).count(), 2, "tags deduplicated ignoring case: {f:?}"); + assert_eq!(parse_tags(" a, b ;; c ,A"), ["a", "b", "c"]); + } +} diff --git a/apps/jiffshot/app/src/main.rs b/apps/jiffshot/app/src/main.rs new file mode 100644 index 0000000..15bfbd4 --- /dev/null +++ b/apps/jiffshot/app/src/main.rs @@ -0,0 +1,542 @@ +//! Jiffshot: one process, every window on Rustolonia's code-first API. This +//! crate owns the editor's state (`app.rs`) and builds its windows (`ui`); +//! the document model, rendering and file formats live in `jiffshot-doc`, +//! capture in `jiffshot-capture` and the capture side's flows in +//! `jiffshot-workflow`. + +// A GUI program in every build, not a console one. +#![windows_subsystem = "windows"] + +mod app; +mod canvas; +mod capture_window; +mod library; +mod ocr; +mod settings; +mod share; +mod trace; +mod prefs; +mod tray; +mod ui; +mod video; +mod vm; +mod vm_support; +mod winutil; + +use app::act; +use rustolonia::{App, Result}; +use vm::{MainViewModel, MainViewModelSink}; +use jiffshot_doc::editor::Tool; +use jiffshot_doc::object::{Cap, Dash, VAlign}; +use jiffshot_doc::text::Align; +use jiffshot_doc::{Document, Editor, Rgba}; + +/// The window's view model; every call is forwarded to [`app::App`]. +struct Model; + +fn color(value: &str) -> Option { + Rgba::parse(value) +} + +fn cap(value: &str) -> Cap { + Cap::from_name(value) +} + +macro_rules! command { + ($name:ident, |$app:ident| $body:expr) => { + fn $name(&mut self) -> Result<()> { + act(|$app| { + $body; + }) + } + }; +} + +macro_rules! tool { + ($name:ident, $tool:expr) => { + fn $name(&mut self) -> Result<()> { + act(|app| app.set_tool($tool)) + } + }; +} + +impl MainViewModel for Model { + fn attach(&mut self, sink: MainViewModelSink) -> Result<()> { + app::with(|app| app.attach(sink)).unwrap_or(Ok(())) + } + + fn detach(&mut self) -> Result<()> { + app::with(|app| app.detach()); + Ok(()) + } + + fn set_stroke_color(&mut self, value: String) -> Result<()> { + let Some(c) = color(&value) else { return Ok(()) }; + act(|app| app.style_edit("stroke", move |s| s.stroke = c)) + } + + fn set_fill_color(&mut self, value: String) -> Result<()> { + let Some(c) = color(&value) else { return Ok(()) }; + act(|app| app.style_edit("fill", move |s| s.fill = c)) + } + + fn set_text_color(&mut self, value: String) -> Result<()> { + let Some(c) = color(&value) else { return Ok(()) }; + act(|app| app.style_edit("textcolor", move |s| s.text_color = c)) + } + + fn set_stroke_width(&mut self, value: f64) -> Result<()> { + act(|app| app.style_edit("width", move |s| s.width = value.clamp(0.0, 200.0) as f32)) + } + + fn set_font_size(&mut self, value: f64) -> Result<()> { + let size = value.clamp(4.0, 400.0) as f32; + act(|app| { + app.style_edit("size", move |s| s.font_size = size); + canvas::with_editor(|e| e.resize_selected_step(size)); + }) + } + + fn set_font_family(&mut self, value: String) -> Result<()> { + if value.is_empty() { + return Ok(()); + } + act(|app| app.style_edit("font", move |s| s.font = value.clone())) + } + + fn set_bold(&mut self, value: bool) -> Result<()> { + act(|app| app.style_edit("bold", move |s| s.bold = value)) + } + + fn set_italic(&mut self, value: bool) -> Result<()> { + act(|app| app.style_edit("italic", move |s| s.italic = value)) + } + + fn set_shadow(&mut self, value: bool) -> Result<()> { + act(|app| app.style_edit("shadow", move |s| s.shadow = value)) + } + + fn set_opacity(&mut self, value: f64) -> Result<()> { + act(|app| app.style_edit("opacity", move |s| s.opacity = (value / 100.0).clamp(0.05, 1.0) as f32)) + } + + fn set_text(&mut self, value: String) -> Result<()> { + act(|app| app.set_text(&value)) + } + + fn set_variant(&mut self, value: String) -> Result<()> { + act(|app| app.set_variant(&value)) + } + + fn set_amount(&mut self, value: f64) -> Result<()> { + act(|app| app.set_amount(value)) + } + + fn set_align(&mut self, value: String) -> Result<()> { + let align = match value.as_str() { + "Center" => Align::Center, + "Right" => Align::Right, + _ => Align::Left, + }; + act(|app| app.style_edit("align", move |s| s.align = align)) + } + + fn set_dash(&mut self, value: String) -> Result<()> { + let dash = match value.as_str() { + "Dash" => Dash::Dash, + "Dot" => Dash::Dot, + _ => Dash::Solid, + }; + act(|app| app.style_edit("dash", move |s| s.dash = dash)) + } + + fn set_start_cap(&mut self, value: String) -> Result<()> { + let c = cap(&value); + act(|app| app.style_edit("startcap", move |s| s.start_cap = c)) + } + + fn set_end_cap(&mut self, value: String) -> Result<()> { + let c = cap(&value); + act(|app| app.style_edit("endcap", move |s| s.end_cap = c)) + } + + fn set_action(&mut self, _value: String) -> Result<()> { + Ok(()) + } + + fn set_video_position(&mut self, value: f64) -> Result<()> { + act(|app| video::seek(app, value)) + } + + fn set_rotation(&mut self, value: f64) -> Result<()> { + act(|app| app.set_rotation(value)) + } + + fn set_head_size(&mut self, value: f64) -> Result<()> { + act(|app| app.style_edit("head", move |s| s.head_size = value.clamp(0.3, 4.0) as f32)) + } + + fn set_shadow_angle(&mut self, value: f64) -> Result<()> { + act(|app| app.style_edit("shadowangle", move |s| s.shadow_angle = value as f32)) + } + + fn set_shadow_distance(&mut self, value: f64) -> Result<()> { + act(|app| app.style_edit("shadowdistance", move |s| s.shadow_distance = value.clamp(0.0, 60.0) as f32)) + } + + fn set_shadow_blur(&mut self, value: f64) -> Result<()> { + act(|app| app.style_edit("shadowblur", move |s| s.shadow_blur = value.clamp(0.0, 40.0) as f32)) + } + + fn set_shadow_color(&mut self, value: String) -> Result<()> { + let Some(c) = color(&value) else { return Ok(()) }; + act(|app| app.style_edit("shadowcolor", move |s| s.shadow_color = c)) + } + + fn set_outline_color(&mut self, value: String) -> Result<()> { + let Some(c) = color(&value) else { return Ok(()) }; + act(|app| app.style_edit("outline", move |s| s.outline = c)) + } + + fn set_outline_width(&mut self, value: f64) -> Result<()> { + act(|app| app.style_edit("outlinewidth", move |s| s.outline_width = value.clamp(0.0, 20.0) as f32)) + } + + fn set_underline(&mut self, value: bool) -> Result<()> { + act(|app| app.style_edit("underline", move |s| s.underline = value)) + } + + fn set_strike(&mut self, value: bool) -> Result<()> { + act(|app| app.style_edit("strike", move |s| s.strike = value)) + } + + fn set_v_align(&mut self, value: String) -> Result<()> { + let v = match value.as_str() { + "Middle" => VAlign::Middle, + "Bottom" => VAlign::Bottom, + _ => VAlign::Top, + }; + act(|app| app.style_edit("valign", move |s| s.valign = v)) + } + + fn set_padding(&mut self, value: f64) -> Result<()> { + act(|app| app.style_edit("padding", move |s| s.padding = value.clamp(-1.0, 100.0) as f32)) + } + + fn set_fill2(&mut self, value: String) -> Result<()> { + let Some(c) = color(&value) else { return Ok(()) }; + act(|app| app.style_edit("fill2", move |s| s.fill2 = c)) + } + + fn set_gradient_radial(&mut self, value: bool) -> Result<()> { + act(|app| app.style_edit("radial", move |s| s.gradient_radial = value)) + } + + fn set_gradient_angle(&mut self, value: f64) -> Result<()> { + act(|app| app.style_edit("gradangle", move |s| s.gradient_angle = value as f32)) + } + + fn set_canvas_color(&mut self, value: String) -> Result<()> { + let Some(c) = color(&value) else { return Ok(()) }; + act(|_app| { + canvas::with_editor(|e| e.canvas_color = c); + }) + } + + command!(expand_canvas, |app| app.document_expand()); + command!(cycle_theme, |app| app.cycle_theme()); + command!(prev_frame, |app| app.video_action("prev")); + command!(next_frame, |app| app.video_action("next")); + command!(cut_selection, |app| app.video_action("cut")); + command!(silent_copy, |app| app.video_action("silent")); + command!(combine_videos, |app| app.video_action("combine")); + command!(video_from_images, |app| app.video_action("slideshow")); + command!(toggle_mute, |app| app.video_action("mute")); + command!(toggle_library_view, |app| app.library_view(|v| v.expanded = !v.expanded)); + command!(combine_selected, |app| app.combine_selected()); + command!(clear_library_selection, |app| app.clear_library_selection()); + command!(show_closed, |app| app.show_closed()); + command!(reset_toolbar, |app| app.run("toolbar:reset")); + command!(open_capture_window, |app| app.open_capture_window()); + command!(auto_trim, |app| app.auto_trim()); + command!(tool_crop_menu, |app| app.set_tool(jiffshot_doc::Tool::Crop)); + + fn open_recent_file(&mut self, value: String) -> Result<()> { + act(|app| app.open_recent(&value)) + } + command!(narrate_images, |app| app.video_action("narrate")); + command!(narrate_next, |app| app.video_action("narrate-next")); + command!(narrate_finish, |app| app.video_action("narrate-finish")); + + fn set_template_theme(&mut self, value: String) -> Result<()> { + act(|app| app.template_prefs(|t| t.theme = value)) + } + + fn set_template_captions(&mut self, value: bool) -> Result<()> { + act(|app| app.template_prefs(|t| t.captions = value)) + } + + fn set_template_numbers(&mut self, value: bool) -> Result<()> { + act(|app| app.template_prefs(|t| t.numbers = value)) + } + + fn set_template_footer(&mut self, value: String) -> Result<()> { + act(|app| app.template_prefs(|t| t.footer = value)) + } + + fn set_library_search(&mut self, value: String) -> Result<()> { + act(|app| app.library_view(|v| v.search = value)) + } + + fn set_library_filter(&mut self, value: String) -> Result<()> { + if value.is_empty() { + return Ok(()); + } + act(|app| app.library_view(|v| v.filter = value)) + } + + fn set_library_sort(&mut self, value: String) -> Result<()> { + if value.is_empty() { + return Ok(()); + } + act(|app| app.library_view(|v| v.sort = value)) + } + + fn set_thumb_size(&mut self, value: f64) -> Result<()> { + act(|app| app.library_view(|v| v.thumb = value.clamp(60.0, 240.0))) + } + + fn set_show_side_bar(&mut self, value: bool) -> Result<()> { + act(|app| app.layout(|l| l.side_bar = value)) + } + + fn set_show_effects(&mut self, value: bool) -> Result<()> { + act(|app| app.layout(|l| l.effects = value)) + } + + fn set_show_tray(&mut self, value: bool) -> Result<()> { + act(|app| app.layout(|l| l.tray = value)) + } + + fn set_tool_labels(&mut self, value: bool) -> Result<()> { + act(|app| app.layout(|l| l.labels = value)) + } + + fn set_library_expanded(&mut self, value: bool) -> Result<()> { + act(|app| app.library_view(|v| v.expanded = value)) + } + + fn set_gif_preset(&mut self, value: String) -> Result<()> { + if !settings::GIF_PRESETS.contains(&value.as_str()) { + return Ok(()); + } + act(|app| app.video_prefs(|p| p.gif_preset = value)) + } + + fn set_gif_loop(&mut self, value: bool) -> Result<()> { + act(|app| app.video_prefs(|p| p.gif_loop = value)) + } + + fn set_gif_fade(&mut self, value: bool) -> Result<()> { + act(|app| app.video_prefs(|p| p.gif_fade = value)) + } + + fn set_gif_dither(&mut self, value: bool) -> Result<()> { + act(|app| app.video_prefs(|p| p.gif_dither = value)) + } + + fn set_video_volume(&mut self, value: f64) -> Result<()> { + act(|app| { + app.video_prefs(|p| { + p.volume = value.clamp(0.0, 1.0); + p.muted = false; + }) + }) + } + + fn set_background_fill(&mut self, value: String) -> Result<()> { + act(|app| app.set_background_fill(&value)) + } + + command!(select_all, |_app| canvas::with_editor(|e| e.select_all())); + command!(deselect_all, |_app| canvas::with_editor(|e| e.set_selection(Vec::new()))); + command!(invert_selection, |_app| canvas::with_editor(|e| e.invert_selection())); + command!(group, |_app| canvas::with_editor(|e| e.group_selected())); + command!(ungroup, |_app| canvas::with_editor(|e| e.ungroup_selected())); + command!(flatten_selected, |_app| canvas::with_editor(|e| e.flatten_selected())); + command!(smart_redact, |app| app.smart_redact()); + command!(new_from_clipboard, |app| app.new_from_clipboard()); + command!(print, |app| app.print()); + command!(close_file, |app| app.close_file()); + command!(duplicate_file, |app| app.duplicate_file()); + command!(delete_file, |app| app.delete_file()); + tool!(tool_magic_wand, Tool::MagicWand); + tool!(tool_eraser, Tool::Eraser); + + command!(new_image, |app| app.new_image()); + command!(open, |app| app.open()); + command!(save, |app| app.save()); + command!(save_as, |app| app.save_as()); + command!(export, |app| app.export()); + command!(copy_image, |app| app.copy_image()); + command!(paste, |app| app.paste()); + command!(new_capture, |app| app.new_capture()); + command!(combine_images, |app| app.combine_images()); + command!(open_library_folder, |app| app.open_library_folder()); + command!(exit, |app| app.exit()); + command!(undo, |_app| canvas::with_editor(|e| e.undo())); + command!(redo, |_app| canvas::with_editor(|e| e.redo())); + command!(copy, |app| app.copy()); + command!(cut, |app| app.cut()); + command!(delete, |_app| canvas::with_editor(|e| e.delete_selected())); + command!(duplicate, |_app| canvas::with_editor(|e| e.duplicate_selected())); + command!(bring_to_front, |_app| canvas::with_editor(|e| e.arrange(2))); + command!(bring_forward, |_app| canvas::with_editor(|e| e.arrange(1))); + command!(send_backward, |_app| canvas::with_editor(|e| e.arrange(-1))); + command!(send_to_back, |_app| canvas::with_editor(|e| e.arrange(-2))); + command!(save_quick_style, |app| app.save_quick_style()); + command!(rotate_left, |app| app.document_op("Rotate left", |d| { + d.rotate(false); + true + })); + command!(rotate_right, |app| app.document_op("Rotate right", |d| { + d.rotate(true); + true + })); + command!(flip_horizontal, |app| app.document_op("Flip horizontal", |d| { + d.flip(true); + true + })); + command!(flip_vertical, |app| app.document_op("Flip vertical", |d| { + d.flip(false); + true + })); + command!(resize50, |app| app.resize(0.5)); + command!(resize200, |app| app.resize(2.0)); + command!(flatten, |app| app.document_op("Flatten", |d| { + let any = !d.objects.is_empty(); + d.flatten_objects(); + any + })); + command!(zoom_in, |app| app.run("zoom:in")); + command!(zoom_out, |app| app.run("zoom:out")); + command!(zoom_fit, |app| app.run("zoom:fit")); + command!(zoom_actual, |app| app.run("zoom:actual")); + command!(grab_text, |app| app.grab_text()); + command!(simplify, |app| app.simplify()); + command!(play_pause, |app| app.video_action("play")); + command!(set_trim_start, |app| app.video_action("start")); + command!(set_trim_end, |app| app.video_action("end")); + command!(export_video, |app| app.video_action("export-mp4")); + command!(export_gif, |app| app.video_action("export-gif")); + command!(frame_to_image, |app| app.video_action("frame")); + command!(record_video, |app| app.video_action("record")); + command!(canvas_changed, |_app| ()); + tool!(tool_select, Tool::Select); + tool!(tool_arrow, Tool::Arrow); + tool!(tool_line, Tool::Line); + tool!(tool_shape, Tool::Shape); + tool!(tool_text, Tool::Text); + tool!(tool_callout, Tool::Callout); + tool!(tool_step, Tool::Step); + tool!(tool_pen, Tool::Pen); + tool!(tool_highlight, Tool::Highlight); + tool!(tool_blur, Tool::Blur); + tool!(tool_magnify, Tool::Magnify); + tool!(tool_spotlight, Tool::Spotlight); + tool!(tool_stamp, Tool::Stamp); + tool!(tool_fill, Tool::Fill); + tool!(tool_crop, Tool::Crop); + tool!(tool_cut_out, Tool::CutOut); + tool!(tool_selection, Tool::Selection); + tool!(tool_eyedropper, Tool::Eyedropper); + tool!(tool_smart_move, Tool::SmartMove); + tool!(tool_text_replace, Tool::TextReplace); + + fn run(&mut self, value: String) -> Result<()> { + act(|app| app.run(&value)) + } +} + +fn main() -> rustolonia::Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + // Command-line tools and self-tests run and exit without the app. + if let Some(code) = jiffshot_workflow::run_command(&args) { + std::process::exit(code); + } + let files: Vec = args.iter().filter(|a| !a.starts_with('-')).map(std::path::PathBuf::from).collect(); + let capture = jiffshot_workflow::capture_flag(&args); + // The UI self-test drives the editor alone: no tray, hotkeys or + // single-instance claim, and closing its window ends it. + let tray = std::env::var_os("JIFFSHOT_UITEST").is_none(); + // One Jiffshot per session: a second launch hands its file or capture + // to the running one and exits. + if tray && !jiffshot_workflow::claim_instance() { + match files.first().and_then(|f| std::path::absolute(f).ok()) { + Some(file) => { + jiffshot_capture::ipc::send_to_editor(&file); + } + None => jiffshot_workflow::forward_to_running(capture), + } + return Ok(()); + } + let show_editor = !tray || !files.is_empty(); + jiffshot_capture::screen::enable_per_monitor_dpi(); + let mut editor = Editor::new(Document::blank(1280, 720, Rgba::WHITE)); + editor.analyzer = Some(Box::new(ocr::Recognizer)); + *canvas::EDITOR.lock() = Some(editor); + let result = App::load_from_env()?.run(move |scope| { + let opening = scope.activation_items()?.iter().find_map(|item| item.local_path().map(|p| p.to_path_buf())); + vm_support::install(scope); + app::install(scope.clone(), opening); + // A second editor hands its file to this one (see the capture + // tool's hand-off); the pipe belongs to the first editor. + let _ = jiffshot_capture::ipc::serve(|path| app::post(move |app| app.incoming(path))); + if tray { + // The tray keeps Jiffshot running with the editor closed. + scope.set_shutdown_mode(rustolonia::ShutdownMode::OnExplicitShutdown)?; + } + ui::mount_main_window(scope, Model, show_editor)?; + if let Some(window) = scope.main_window() { + winutil::set_main_hwnd(scope.platform_handle(&window).unwrap_or(0)); + } + // The Capture window, hidden until asked for (the UI self-test + // drives it too). + let window = ui::mount_capture_window(scope, capture_window::CaptureModel)?; + capture_window::set_window(window); + if tray { + let hooks = jiffshot_workflow::Hooks { + open_in_editor: Box::new(|path| { + let path = path.to_path_buf(); + app::post(move |app| app.incoming(path)); + true + }), + show_editor: Box::new(|| app::post(|app| app.show_editor())), + quit: Box::new(|| app::post(|app| app.exit())), + notify: Box::new(|title, text, problem| { + let (title, text) = (title.to_owned(), text.to_owned()); + app::post(move |app| app.toast(&title, &text, problem)); + }), + state: Box::new(|state| { + app::post(move |app| { + app.set_widget(state.show_widget); + capture_window::refresh(&state); + tray::publish(state); + }) + }), + show_capture_window: Box::new(|| app::post(|app| app.show_capture_window())), + show_text: Box::new(|text| app::post(move |app| app.show_grab_text(&text))), + hud: Box::new(app::post_hud), + overlay: Box::new(|json| app::post_quiet(move |app| app.set_overlay(&json))), + screen_draw: Box::new(|json| app::post_quiet(move |app| app.set_screen_draw(&json))), + }; + if let Err(error) = jiffshot_workflow::start_engine(capture, hooks) { + eprintln!("the capture side did not start: {error}"); + } + ui::mount_tray(scope, tray::TrayModel)?; + } + Ok(()) + }); + ui::detach_all(); + result +} diff --git a/apps/jiffshot/app/src/ocr.rs b/apps/jiffshot/app/src/ocr.rs new file mode 100644 index 0000000..b9db2eb --- /dev/null +++ b/apps/jiffshot/app/src/ocr.rs @@ -0,0 +1,53 @@ +//! Text recognition for Grab Text, Text Replace and Simplify, through the +//! Windows OCR engine in `jiffshot-capture`. + +use jiffshot_doc::editor::Analyzer; +use jiffshot_doc::smart::TextLine; +use jiffshot_doc::{IRect, Pixmap}; + +fn bgra(pm: &Pixmap) -> Vec { + let mut out = Vec::with_capacity(pm.data().len()); + for p in pm.pixels() { + // Composite over white so transparent areas read as background. + let inv = 255 - p.alpha() as u16; + let c = |v: u8| (v as u16 + inv).min(255) as u8; + out.extend_from_slice(&[c(p.blue()), c(p.green()), c(p.red()), 255]); + } + out +} + +/// Lines of text in `pm`, in its pixel coordinates. +pub fn recognize(pm: &Pixmap) -> Result, String> { + let lines = jiffshot_capture::ocr::recognize(pm.width(), pm.height(), &bgra(pm))?; + Ok(lines + .into_iter() + .map(|l| TextLine { + area: IRect::new(l.rect.x, l.rect.y, l.rect.width, l.rect.height), + text: l.text, + words: l.words.into_iter().map(|w| (IRect::new(w.rect.x, w.rect.y, w.rect.width, w.rect.height), w.text)).collect(), + }) + .collect()) +} + +/// The editor's hook for Text Replace. +pub struct Recognizer; + +impl Analyzer for Recognizer { + fn text_in(&mut self, image: &Pixmap, area: IRect) -> Vec { + let Some(area) = area.intersect(&IRect::of_size(image.width(), image.height())) else { return Vec::new() }; + let Some(part) = jiffshot_doc::raster::extract(image, area) else { return Vec::new() }; + recognize(&part) + .unwrap_or_default() + .into_iter() + .map(|mut l| { + l.area.x += area.x; + l.area.y += area.y; + for (r, _) in &mut l.words { + r.x += area.x; + r.y += area.y; + } + l + }) + .collect() + } +} diff --git a/apps/jiffshot/app/src/prefs.rs b/apps/jiffshot/app/src/prefs.rs new file mode 100644 index 0000000..2b51739 --- /dev/null +++ b/apps/jiffshot/app/src/prefs.rs @@ -0,0 +1,136 @@ +//! The one Preferences dialog: Capture, Editor, Library, Share and Hotkeys +//! tabs. Each tab is a list of rows, as in the Capture window's panels +//! (`jiffshot_workflow::panel::Row`); the dialog sends back one value per +//! row and tab, and this module applies them: the capture side's tabs +//! through `jiffshot_workflow::prefs`, the editor's through its own actions. + +use crate::app::App; +use jiffshot_workflow::panel::{check_row, choice, note, on, string, text, Row}; +use jiffshot_workflow::{capture, prefs}; +use serde::Serialize; +use serde_json::Value; + +pub const THEMES: [&str; 3] = ["Dark", "Light", "System"]; +/// Export formats: label, extension ("" for the one used last). +const FORMATS: [(&str, &str); 7] = + [("The one used last", ""), ("PNG", "png"), ("JPEG", "jpg"), ("PDF", "pdf"), ("BMP", "bmp"), ("GIF", "gif"), ("WebP", "webp")]; + +#[derive(Serialize)] +struct Tab { + id: &'static str, + title: &'static str, + rows: Vec, +} + +#[derive(Serialize)] +struct Dialog { + /// The tab to open on. + tab: String, + tabs: Vec, +} + +/// The dialog's JSON, opening on `tab`. +pub fn dialog(app: &App, tab: &str) -> String { + let capture_settings = jiffshot_workflow::settings_snapshot(); + let s = &app.settings; + let theme = THEMES.iter().position(|t| Some(*t) == s.theme.as_deref()).unwrap_or(0); + let format = FORMATS.iter().position(|(_, ext)| *ext == s.export_format.as_deref().unwrap_or("")).unwrap_or(0); + let mut share = vec![ + text("FTP destination (ftp://user@host/folder)", s.ftp.clone().unwrap_or_default()), + note("The FTP password is the \u{201C}Jiffshot FTP\u{201D} entry in Windows Credential Manager."), + ]; + if s.programs.is_empty() { + share.push(note("No programs yet; Share \u{25B8} Add program\u{2026} in the editor adds one.")); + } else { + share.push(note("Programs captures can be sent to (clear one to remove it):")); + share.extend(s.programs.iter().map(|(name, _)| check_row(name, true))); + } + let dialog = Dialog { + tab: tab.to_owned(), + tabs: vec![ + Tab { id: "capture", title: "Capture", rows: prefs::capture(&capture_settings, capture::runs_at_startup()) }, + Tab { + id: "editor", + title: "Editor", + rows: vec![ + choice("Color theme (every Jiffshot window)", &THEMES, theme), + check_row("Show tool names in the toolbar", s.layout.labels), + check_row("Show the side bar (Quick Styles and properties)", s.layout.side_bar), + check_row("Show the Recent Captures tray", s.layout.tray), + choice("Export as", &FORMATS.map(|(label, _)| label), format), + ], + }, + Tab { + id: "library", + title: "Library", + rows: vec![ + Row::Folder { label: "Library folder".into(), value: app.library.root.display().to_string() }, + note("New captures are saved here, and the editor and the tray's Recent captures list them."), + ], + }, + Tab { id: "share", title: "Share", rows: share }, + Tab { id: "hotkeys", title: "Hotkeys", rows: prefs::hotkeys(&capture_settings, &crate::capture_window::capture_key()) }, + ], + }; + serde_json::to_string(&dialog).unwrap_or_default() +} + +/// Applies the dialog's values (`{"capture": [...], "editor": [...], …}`). +pub fn apply(app: &mut App, json: &str) { + let Ok(Value::Object(tabs)) = serde_json::from_str::(json) else { return }; + let values = |id: &str| -> Vec { tabs.get(id).and_then(Value::as_array).cloned().unwrap_or_default() }; + apply_capture(&values("capture"), &values("hotkeys"), app); + apply_editor(app, &values("editor"), &values("library"), &values("share")); +} + +fn apply_capture(values: &[Value], hotkeys: &[Value], app: &mut App) { + let mut s = jiffshot_workflow::settings_snapshot(); + if !values.is_empty() { + if let Some(on) = prefs::apply_capture(&mut s, values, capture::runs_at_startup()) { + capture::set_run_at_startup(on); + } + capture::apply(s.clone()); + } + if !hotkeys.is_empty() { + match prefs::apply_hotkeys(&s.presets, hotkeys) { + Ok(presets) if presets != s.presets => capture::set_presets(presets), + Ok(_) => {} + Err(problem) => app.toast("Hotkeys not changed", &problem, true), + } + } +} + +fn apply_editor(app: &mut App, editor: &[Value], library: &[Value], share: &[Value]) { + if !editor.is_empty() { + let theme = THEMES[(editor[0].as_u64().unwrap_or(0) as usize).min(2)]; + if app.settings.theme.as_deref() != Some(theme) { + app.run(&format!("theme:{theme}")); + } + let (labels, side_bar, tray) = (on(editor, 1), on(editor, 2), on(editor, 3)); + app.layout(|l| { + l.labels = labels; + l.side_bar = side_bar; + l.tray = tray; + }); + let format = FORMATS[(editor.get(4).and_then(Value::as_u64).unwrap_or(0) as usize).min(FORMATS.len() - 1)].1; + if app.settings.export_format.as_deref().unwrap_or("") != format { + app.run(&format!("export-format:{format}")); + } + } + let folder = string(library, 0); + if !folder.is_empty() && std::path::Path::new(&folder) != app.library.root { + app.run(&format!("library-folder:{folder}")); + } + if !share.is_empty() { + let ftp = string(share, 0); + if ftp != app.settings.ftp.clone().unwrap_or_default() { + app.run(&format!("ftp:{ftp}")); + } + // Rows: FTP, its note, a note, then a check per program. + let removed: Vec = + app.settings.programs.iter().enumerate().filter(|(i, _)| share.get(3 + i).is_some_and(|v| v == &Value::Bool(false))).map(|(_, (n, _))| n.clone()).collect(); + for name in removed { + app.run(&format!("program-remove:{name}")); + } + } +} diff --git a/apps/jiffshot/app/src/settings.rs b/apps/jiffshot/app/src/settings.rs new file mode 100644 index 0000000..7852d1d --- /dev/null +++ b/apps/jiffshot/app/src/settings.rs @@ -0,0 +1,333 @@ +//! Editor settings: the `editor` section of Jiffshot's settings file (see +//! `jiffshot_capture::config`). The library folder and the color theme are +//! shared with the capture side and kept at the top of that file. + +use serde::{Deserialize, Serialize}; +use jiffshot_doc::editor::{default_style, Tool}; +use jiffshot_doc::object::{Cap, Dash, Style}; +use jiffshot_doc::Rgba; +use std::collections::BTreeMap; +use std::path::PathBuf; + +#[derive(Default, Serialize, Deserialize)] +#[serde(default)] +pub struct Settings { + /// The shared library folder, as loaded (changed through + /// `config::set_library`). + #[serde(skip)] + pub library: Option, + /// Quick styles saved by the user, by tool name, newest last. + pub quick_styles: BTreeMap>, + /// A tool's whole list of quick styles, once it was reordered, + /// trimmed or themed (built-in ones included). + pub style_lists: BTreeMap>, + /// Favorite quick styles, by tool name; shown first. + pub favorite_styles: BTreeMap>, + /// Share destination `ftp://user@host/folder`; the password is the + /// "Jiffshot FTP" generic credential in Windows Credential Manager. + pub ftp: Option, + /// Programs added as share destinations (name, executable). + pub programs: Vec<(String, PathBuf)>, + /// The shared color theme, "Dark", "Light" or "System", as loaded + /// (changed through `config::set_theme`). + #[serde(skip)] + pub theme: Option, + pub video: VideoPrefs, + pub library_view: LibraryView, + pub layout: Layout, + /// The extension Export used last. + pub export_format: Option, + pub template: TemplatePrefs, + /// The tools shown in the toolbar, in order (all, in the usual order, + /// when empty). + pub toolbar: Vec, + /// Files opened lately, newest first. + pub recent: Vec, +} + +/// Which parts of the window show, kept between sessions. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub struct Layout { + /// Quick Styles and tool properties, on the right. + pub side_bar: bool, + /// The Effects panel, beside the side bar. + pub effects: bool, + /// The Recent Captures tray along the bottom. + pub tray: bool, + /// Tool names under their icons in the toolbar. + pub labels: bool, +} + +impl Default for Layout { + fn default() -> Layout { + Layout { side_bar: true, effects: false, tray: true, labels: true } + } +} + +/// Template choices, kept between sessions. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct TemplatePrefs { + pub theme: String, + pub captions: bool, + pub numbers: bool, + pub footer: String, +} + +/// How the library is shown, kept between sessions. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub struct LibraryView { + pub search: String, + pub filter: String, + pub sort: String, + /// Thumbnail height in the tray, pixels. + pub thumb: f64, + pub expanded: bool, + /// Captures closed from the tray (still in the library). + pub closed: Vec, +} + +impl Default for LibraryView { + fn default() -> LibraryView { + LibraryView { search: String::new(), filter: "All".into(), sort: "Newest".into(), thumb: 120.0, expanded: false, closed: Vec::new() } + } +} + +/// GIF export and playback choices, kept between sessions. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub struct VideoPrefs { + /// "Screen recording", "High motion", "Small file" or "Custom". + pub gif_preset: String, + /// Used by the Custom preset. + pub gif_fps: f64, + pub gif_width: u32, + pub gif_loop: bool, + pub gif_fade: bool, + pub gif_dither: bool, + /// Playback volume, 0 to 1. + pub volume: f64, + pub muted: bool, + /// Seconds each picture shows in a video made from images. + pub slide_seconds: f64, +} + +impl Default for VideoPrefs { + fn default() -> VideoPrefs { + VideoPrefs { + gif_preset: "Screen recording".into(), + gif_fps: 12.0, + gif_width: 800, + gif_loop: true, + gif_fade: false, + gif_dither: false, + volume: 1.0, + muted: false, + slide_seconds: 3.0, + } + } +} + +pub const GIF_PRESETS: &[&str] = &["Screen recording", "High motion", "Small file", "Custom"]; + +impl VideoPrefs { + /// Frames a second and largest width. + pub fn gif(&self) -> (f64, u32) { + match self.gif_preset.as_str() { + "High motion" => (20.0, 640), + "Small file" => (8.0, 480), + "Custom" => (self.gif_fps.clamp(1.0, 50.0), self.gif_width.clamp(64, 3840)), + _ => (10.0, 800), + } + } +} + +fn built_in(tool: Tool) -> Vec