From faa17fb047ca09e6af893a150d6230d2b86218c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 30 Oct 2026 11:47:37 -0400 Subject: [PATCH] Add Jiffshot, a screen capture tool and editor, in apps/jiffshot Jiffshot is a screen capture tool and image/video editor for Windows, written entirely in Rust on Rustolonia's code-first API: no AXAML, no C# and no generated adapters. 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. - Capture: region, window, screen, scrolling, panoramic, freehand, multiple areas, fixed size, menus, Grab Text (OCR) and Step Capture, from a frozen per-monitor selection overlay; delays, time-lapse, schedules and presets; the Capture window and Capture Widget. - Recording: an area, window or screen with system sound and/or the microphone, the webcam, pointer effects and Screen Draw, with a toolbar under the area. The pause and stop keys fall back from Shift+F9/F10 when another app holds them, and every window shows the keys in use. - Editor: annotation tools, effects, templates, a capture library, video trimming and GIF export. Layout: crates/jiffshot-capture (capture primitives, media, library), crates/jiffshot-doc (documents, rendering, file formats), crates/jiffshot-workflow (the capture side) and app/ (the editor's model in app.rs, view models in vm.rs, every window in ui/). The editor canvas, the selection overlay and Screen Draw are Rustolonia pixel surfaces; the tray icon is a Rustolonia ShellTray. JIFFSHOT_UITEST=DIR drives the UI and writes screenshots of every window. It builds with plain Cargo against the Rustolonia v12.1.2 release and its prebuilt host, and the folder builds the same way outside this repository. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + apps/jiffshot/.cargo/config.toml | 12 + apps/jiffshot/.gitignore | 2 + apps/jiffshot/Cargo.lock | 1422 +++++++++++ apps/jiffshot/Cargo.toml | 67 + apps/jiffshot/README.md | 102 + apps/jiffshot/app/Cargo.toml | 20 + apps/jiffshot/app/README-portable.txt | 26 + apps/jiffshot/app/build.rs | 35 + apps/jiffshot/app/file-associations/README.md | 8 + .../windows-file-association.reg | 27 + apps/jiffshot/app/src/app.rs | 2246 +++++++++++++++++ apps/jiffshot/app/src/canvas.rs | 175 ++ apps/jiffshot/app/src/capture_window.rs | 251 ++ apps/jiffshot/app/src/library.rs | 433 ++++ apps/jiffshot/app/src/main.rs | 542 ++++ apps/jiffshot/app/src/ocr.rs | 53 + apps/jiffshot/app/src/prefs.rs | 136 + apps/jiffshot/app/src/settings.rs | 333 +++ apps/jiffshot/app/src/share.rs | 121 + apps/jiffshot/app/src/trace.rs | 39 + apps/jiffshot/app/src/tray.rs | 183 ++ apps/jiffshot/app/src/ui/canvas_view.rs | 694 +++++ .../app/src/ui/canvas_view/canvas_overlay.rs | 384 +++ apps/jiffshot/app/src/ui/capture_view.rs | 705 ++++++ .../jiffshot/app/src/ui/capture_view/parts.rs | 389 +++ apps/jiffshot/app/src/ui/dialogs.rs | 231 ++ apps/jiffshot/app/src/ui/extras.rs | 293 +++ apps/jiffshot/app/src/ui/hud.rs | 302 +++ apps/jiffshot/app/src/ui/hud/hud_toolbar.rs | 544 ++++ apps/jiffshot/app/src/ui/hud/native.rs | 126 + apps/jiffshot/app/src/ui/main_window.rs | 262 ++ .../app/src/ui/main_window/main_bars.rs | 160 ++ .../app/src/ui/main_window/main_common.rs | 398 +++ .../app/src/ui/main_window/main_library.rs | 178 ++ .../app/src/ui/main_window/main_menu.rs | 442 ++++ .../app/src/ui/main_window/main_panel.rs | 372 +++ .../app/src/ui/main_window/main_tools.rs | 371 +++ apps/jiffshot/app/src/ui/mod.rs | 202 ++ apps/jiffshot/app/src/ui/overlay.rs | 623 +++++ .../app/src/ui/overlay/overlay_api.rs | 57 + .../app/src/ui/overlay/overlay_view.rs | 633 +++++ apps/jiffshot/app/src/ui/prefs_window.rs | 162 ++ apps/jiffshot/app/src/ui/selftest.rs | 1198 +++++++++ apps/jiffshot/app/src/ui/toast.rs | 179 ++ apps/jiffshot/app/src/ui/tray.rs | 206 ++ apps/jiffshot/app/src/video.rs | 910 +++++++ apps/jiffshot/app/src/vm.rs | 1444 +++++++++++ apps/jiffshot/app/src/vm_support.rs | 278 ++ apps/jiffshot/app/src/winutil.rs | 142 ++ apps/jiffshot/assets/icon_res.rs | 74 + apps/jiffshot/assets/jiffshot.ico | Bin 0 -> 25492 bytes .../crates/jiffshot-capture/Cargo.toml | 13 + .../crates/jiffshot-capture/src/clipboard.rs | 272 ++ .../crates/jiffshot-capture/src/config.rs | 136 + .../crates/jiffshot-capture/src/frame.rs | 137 + .../crates/jiffshot-capture/src/gdi.rs | 102 + .../crates/jiffshot-capture/src/geom.rs | 107 + .../crates/jiffshot-capture/src/ipc.rs | 94 + .../crates/jiffshot-capture/src/lib.rs | 36 + .../crates/jiffshot-capture/src/library.rs | 243 ++ .../crates/jiffshot-capture/src/media.rs | 632 +++++ .../crates/jiffshot-capture/src/ocr.rs | 105 + .../crates/jiffshot-capture/src/paths.rs | 17 + .../crates/jiffshot-capture/src/screen.rs | 281 +++ .../crates/jiffshot-capture/src/share.rs | 140 + .../crates/jiffshot-capture/src/stitch.rs | 291 +++ .../crates/jiffshot-capture/src/targets.rs | 297 +++ apps/jiffshot/crates/jiffshot-doc/Cargo.toml | 14 + .../crates/jiffshot-doc/examples/effects.rs | 27 + .../crates/jiffshot-doc/examples/gallery.rs | 49 + .../crates/jiffshot-doc/examples/render.rs | 13 + .../crates/jiffshot-doc/examples/synthetic.rs | 53 + .../jiffshot/crates/jiffshot-doc/src/color.rs | 134 + .../crates/jiffshot-doc/src/combine.rs | 306 +++ .../crates/jiffshot-doc/src/document.rs | 417 +++ .../crates/jiffshot-doc/src/editor.rs | 2063 +++++++++++++++ .../crates/jiffshot-doc/src/effects.rs | 570 +++++ apps/jiffshot/crates/jiffshot-doc/src/geom.rs | 259 ++ apps/jiffshot/crates/jiffshot-doc/src/io.rs | 296 +++ apps/jiffshot/crates/jiffshot-doc/src/lib.rs | 24 + .../crates/jiffshot-doc/src/object.rs | 1545 ++++++++++++ .../crates/jiffshot-doc/src/raster.rs | 372 +++ .../crates/jiffshot-doc/src/redact.rs | 213 ++ .../jiffshot/crates/jiffshot-doc/src/smart.rs | 398 +++ .../crates/jiffshot-doc/src/stamps.rs | 220 ++ apps/jiffshot/crates/jiffshot-doc/src/text.rs | 327 +++ .../crates/jiffshot-doc/tests/walkthrough.rs | 276 ++ .../crates/jiffshot-workflow/Cargo.toml | 15 + .../crates/jiffshot-workflow/src/hud.rs | 433 ++++ .../crates/jiffshot-workflow/src/icon.rs | 71 + .../crates/jiffshot-workflow/src/lib.rs | 632 +++++ .../crates/jiffshot-workflow/src/modes.rs | 290 +++ .../crates/jiffshot-workflow/src/overlay.rs | 629 +++++ .../crates/jiffshot-workflow/src/panel.rs | 1032 ++++++++ .../crates/jiffshot-workflow/src/prefs.rs | 122 + .../crates/jiffshot-workflow/src/recorder.rs | 605 +++++ .../jiffshot-workflow/src/screendraw.rs | 266 ++ .../jiffshot-workflow/src/selftest_scroll.rs | 322 +++ .../crates/jiffshot-workflow/src/settings.rs | 179 ++ .../crates/jiffshot-workflow/src/stepcap.rs | 203 ++ .../crates/jiffshot-workflow/src/tray.rs | 1390 ++++++++++ .../crates/jiffshot-workflow/src/ui_api.rs | 288 +++ .../crates/jiffshot-workflow/src/vfx.rs | 239 ++ .../crates/jiffshot-workflow/src/workflow.rs | 442 ++++ apps/jiffshot/docs/architecture.md | 74 + apps/jiffshot/rust-toolchain.toml | 4 + 107 files changed, 35908 insertions(+) create mode 100644 apps/jiffshot/.cargo/config.toml create mode 100644 apps/jiffshot/.gitignore create mode 100644 apps/jiffshot/Cargo.lock create mode 100644 apps/jiffshot/Cargo.toml create mode 100644 apps/jiffshot/README.md create mode 100644 apps/jiffshot/app/Cargo.toml create mode 100644 apps/jiffshot/app/README-portable.txt create mode 100644 apps/jiffshot/app/build.rs create mode 100644 apps/jiffshot/app/file-associations/README.md create mode 100644 apps/jiffshot/app/file-associations/windows-file-association.reg create mode 100644 apps/jiffshot/app/src/app.rs create mode 100644 apps/jiffshot/app/src/canvas.rs create mode 100644 apps/jiffshot/app/src/capture_window.rs create mode 100644 apps/jiffshot/app/src/library.rs create mode 100644 apps/jiffshot/app/src/main.rs create mode 100644 apps/jiffshot/app/src/ocr.rs create mode 100644 apps/jiffshot/app/src/prefs.rs create mode 100644 apps/jiffshot/app/src/settings.rs create mode 100644 apps/jiffshot/app/src/share.rs create mode 100644 apps/jiffshot/app/src/trace.rs create mode 100644 apps/jiffshot/app/src/tray.rs create mode 100644 apps/jiffshot/app/src/ui/canvas_view.rs create mode 100644 apps/jiffshot/app/src/ui/canvas_view/canvas_overlay.rs create mode 100644 apps/jiffshot/app/src/ui/capture_view.rs create mode 100644 apps/jiffshot/app/src/ui/capture_view/parts.rs create mode 100644 apps/jiffshot/app/src/ui/dialogs.rs create mode 100644 apps/jiffshot/app/src/ui/extras.rs create mode 100644 apps/jiffshot/app/src/ui/hud.rs create mode 100644 apps/jiffshot/app/src/ui/hud/hud_toolbar.rs create mode 100644 apps/jiffshot/app/src/ui/hud/native.rs create mode 100644 apps/jiffshot/app/src/ui/main_window.rs create mode 100644 apps/jiffshot/app/src/ui/main_window/main_bars.rs create mode 100644 apps/jiffshot/app/src/ui/main_window/main_common.rs create mode 100644 apps/jiffshot/app/src/ui/main_window/main_library.rs create mode 100644 apps/jiffshot/app/src/ui/main_window/main_menu.rs create mode 100644 apps/jiffshot/app/src/ui/main_window/main_panel.rs create mode 100644 apps/jiffshot/app/src/ui/main_window/main_tools.rs create mode 100644 apps/jiffshot/app/src/ui/mod.rs create mode 100644 apps/jiffshot/app/src/ui/overlay.rs create mode 100644 apps/jiffshot/app/src/ui/overlay/overlay_api.rs create mode 100644 apps/jiffshot/app/src/ui/overlay/overlay_view.rs create mode 100644 apps/jiffshot/app/src/ui/prefs_window.rs create mode 100644 apps/jiffshot/app/src/ui/selftest.rs create mode 100644 apps/jiffshot/app/src/ui/toast.rs create mode 100644 apps/jiffshot/app/src/ui/tray.rs create mode 100644 apps/jiffshot/app/src/video.rs create mode 100644 apps/jiffshot/app/src/vm.rs create mode 100644 apps/jiffshot/app/src/vm_support.rs create mode 100644 apps/jiffshot/app/src/winutil.rs create mode 100644 apps/jiffshot/assets/icon_res.rs create mode 100644 apps/jiffshot/assets/jiffshot.ico create mode 100644 apps/jiffshot/crates/jiffshot-capture/Cargo.toml create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/clipboard.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/config.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/frame.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/gdi.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/geom.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/ipc.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/lib.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/library.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/media.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/ocr.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/paths.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/screen.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/share.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/stitch.rs create mode 100644 apps/jiffshot/crates/jiffshot-capture/src/targets.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/Cargo.toml create mode 100644 apps/jiffshot/crates/jiffshot-doc/examples/effects.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/examples/gallery.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/examples/render.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/examples/synthetic.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/color.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/combine.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/document.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/editor.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/effects.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/geom.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/io.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/lib.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/object.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/raster.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/redact.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/smart.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/stamps.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/src/text.rs create mode 100644 apps/jiffshot/crates/jiffshot-doc/tests/walkthrough.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/Cargo.toml create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/hud.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/icon.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/lib.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/modes.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/overlay.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/panel.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/prefs.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/recorder.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/screendraw.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/selftest_scroll.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/settings.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/stepcap.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/tray.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/ui_api.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/vfx.rs create mode 100644 apps/jiffshot/crates/jiffshot-workflow/src/workflow.rs create mode 100644 apps/jiffshot/docs/architecture.md create mode 100644 apps/jiffshot/rust-toolchain.toml 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