From e62b7ccd15489ab89ddc4658df380c409d2b7efe Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 00:08:13 -0400 Subject: [PATCH] M0: PowerPoint-style egui UI, slide show engine, icons, desktop app - ui-egui: title bar, ribbon with every tab (Home, Insert, Draw, Design, Transitions, Animations, Slide Show, Record, Review, View) plus contextual Shape/Picture Format, Table Design/Layout, Chart Design, Playback and Slide Master tabs; thumbnail pane with sections and drag reorder; canvas with selection handles, smart guides, marquee, in-place text editing; notes pane; status bar with views and zoom; task panes (Format Shape, Background, Animation, Selection, Comments, Design Ideas); dialogs; Slide Sorter, Notes Page, Outline view; command palette; control channel - anim: timelines for every animation effect, transition layers for every transition, morph pairing, show navigation (hidden slides, custom shows, loops) - show: full-screen slide show with transitions, animations, pen, black/white screens, presenter view window, rehearse timings, reading view - icons: 238 original icons drawn in code - render: filters force single-threaded rendering (regression test), panic guard - engine: chart type/data/options, SmartArt-style graphics - apps/slidecraft: desktop host with control server, file dialogs, clipboard images - placeholder app icon rendered by SlideCraft itself Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 2 + Cargo.lock | 3664 ++++++++++++++++- Cargo.toml | 3 +- apps/slidecraft/Cargo.toml | 27 + apps/slidecraft/src/control_server.rs | 62 + apps/slidecraft/src/main.rs | 184 + .../apps/ai.storyteller.slidecraft.png | Bin 0 -> 4791 bytes .../16x16/apps/ai.storyteller.slidecraft.png | Bin 0 -> 818 bytes .../24x24/apps/ai.storyteller.slidecraft.png | Bin 0 -> 1147 bytes .../apps/ai.storyteller.slidecraft.png | Bin 0 -> 9327 bytes .../32x32/apps/ai.storyteller.slidecraft.png | Bin 0 -> 1475 bytes .../48x48/apps/ai.storyteller.slidecraft.png | Bin 0 -> 2057 bytes .../apps/ai.storyteller.slidecraft.png | Bin 0 -> 20164 bytes .../64x64/apps/ai.storyteller.slidecraft.png | Bin 0 -> 2519 bytes assets/app-icon/icon-source.slidecraft | Bin 0 -> 4983 bytes assets/app-icon/slidecraft-1024.png | Bin 0 -> 36607 bytes crates/anim/Cargo.toml | 14 + crates/anim/src/easing.rs | 75 + crates/anim/src/effects.rs | 317 ++ crates/anim/src/lib.rs | 111 + crates/anim/src/morph.rs | 93 + crates/anim/src/path.rs | 293 ++ crates/anim/src/show.rs | 232 ++ crates/anim/src/tests.rs | 634 +++ crates/anim/src/timeline.rs | 476 +++ crates/anim/src/transition.rs | 907 ++++ crates/engine/src/cmd/insert.rs | 194 + crates/engine/src/cmd/mod.rs | 2 +- crates/geom/src/lib.rs | 6 + crates/render/src/lib.rs | 82 +- crates/render/src/table.rs | 6 +- crates/ui-egui/Cargo.toml | 26 + crates/ui-egui/examples/icon_gallery.rs | 65 + crates/ui-egui/src/canvas.rs | 491 +++ crates/ui-egui/src/control.rs | 347 ++ crates/ui-egui/src/dialogs.rs | 782 ++++ crates/ui-egui/src/icons.rs | 2427 +++++++++++ crates/ui-egui/src/lib.rs | 754 ++++ crates/ui-egui/src/menus.rs | 245 ++ crates/ui-egui/src/panes.rs | 648 +++ crates/ui-egui/src/ribbon.rs | 2345 +++++++++++ crates/ui-egui/src/show.rs | 606 +++ crates/ui-egui/src/sorter.rs | 231 ++ crates/ui-egui/src/status.rs | 155 + crates/ui-egui/src/textures.rs | 150 + crates/ui-egui/src/theme.rs | 194 + crates/ui-egui/src/thumbs.rs | 314 ++ crates/ui-egui/src/widgets.rs | 433 ++ 48 files changed, 17564 insertions(+), 33 deletions(-) create mode 100644 apps/slidecraft/Cargo.toml create mode 100644 apps/slidecraft/src/control_server.rs create mode 100644 apps/slidecraft/src/main.rs create mode 100644 assets/app-icon/hicolor/128x128/apps/ai.storyteller.slidecraft.png create mode 100644 assets/app-icon/hicolor/16x16/apps/ai.storyteller.slidecraft.png create mode 100644 assets/app-icon/hicolor/24x24/apps/ai.storyteller.slidecraft.png create mode 100644 assets/app-icon/hicolor/256x256/apps/ai.storyteller.slidecraft.png create mode 100644 assets/app-icon/hicolor/32x32/apps/ai.storyteller.slidecraft.png create mode 100644 assets/app-icon/hicolor/48x48/apps/ai.storyteller.slidecraft.png create mode 100644 assets/app-icon/hicolor/512x512/apps/ai.storyteller.slidecraft.png create mode 100644 assets/app-icon/hicolor/64x64/apps/ai.storyteller.slidecraft.png create mode 100644 assets/app-icon/icon-source.slidecraft create mode 100644 assets/app-icon/slidecraft-1024.png create mode 100644 crates/anim/Cargo.toml create mode 100644 crates/anim/src/easing.rs create mode 100644 crates/anim/src/effects.rs create mode 100644 crates/anim/src/lib.rs create mode 100644 crates/anim/src/morph.rs create mode 100644 crates/anim/src/path.rs create mode 100644 crates/anim/src/show.rs create mode 100644 crates/anim/src/tests.rs create mode 100644 crates/anim/src/timeline.rs create mode 100644 crates/anim/src/transition.rs create mode 100644 crates/ui-egui/Cargo.toml create mode 100644 crates/ui-egui/examples/icon_gallery.rs create mode 100644 crates/ui-egui/src/canvas.rs create mode 100644 crates/ui-egui/src/control.rs create mode 100644 crates/ui-egui/src/dialogs.rs create mode 100644 crates/ui-egui/src/icons.rs create mode 100644 crates/ui-egui/src/lib.rs create mode 100644 crates/ui-egui/src/menus.rs create mode 100644 crates/ui-egui/src/panes.rs create mode 100644 crates/ui-egui/src/ribbon.rs create mode 100644 crates/ui-egui/src/show.rs create mode 100644 crates/ui-egui/src/sorter.rs create mode 100644 crates/ui-egui/src/status.rs create mode 100644 crates/ui-egui/src/textures.rs create mode 100644 crates/ui-egui/src/theme.rs create mode 100644 crates/ui-egui/src/thumbs.rs create mode 100644 crates/ui-egui/src/widgets.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9ee19cd..5c90950 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -17,6 +17,8 @@ deck and UI icons are original work of the SlideCraft contributors (drawn or gen | `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README footer, About | | `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds | | `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks | +| `assets/app-icon/slidecraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.slidecraft.png` | SlideCraft contributors | rendered by SlideCraft from `assets/app-icon/icon-source.slidecraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn | +| `assets/app-icon/icon-source.slidecraft` | SlideCraft contributors | made with `slidecraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon | ## Bundled through dependencies diff --git a/Cargo.lock b/Cargo.lock index 4b22afc..b74b6f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,184 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arbitrary" version = "1.5.0" @@ -17,24 +189,285 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[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 = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -67,12 +500,115 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + [[package]] name = "color" version = "0.3.3" @@ -88,6 +624,75 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -137,6 +742,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "derive_arbitrary" version = "1.5.0" @@ -148,12 +759,275 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster 1.0.1", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster 1.0.1", + "serde", + "tempfile", + "toml", + "wgpu", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust 0.12.0", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa 0.44.0", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu 0.1.0", +] + [[package]] name = "epaint_default_fonts" version = "0.36.2" @@ -166,6 +1040,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "euclid" version = "0.22.14" @@ -175,6 +1065,32 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fax" version = "0.2.7" @@ -196,6 +1112,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "flate2" version = "1.1.10" @@ -222,6 +1144,130 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "gif" version = "0.14.2" @@ -232,6 +1278,33 @@ dependencies = [ "weezl", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa 0.44.0", + "smallvec", + "vello_common 0.1.0", +] + [[package]] name = "glifo" version = "0.3.0" @@ -240,13 +1313,105 @@ checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash", - "hashbrown", + "hashbrown 0.17.1", "log", "peniko", "png", "skrifa 0.44.0", "smallvec", - "vello_common", + "vello_common 0.2.0", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", ] [[package]] @@ -266,22 +1431,46 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts 0.41.0", + "smallvec", +] + [[package]] name = "harfrust" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytemuck", "once_cell", "read-fonts 0.43.3", "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -291,6 +1480,18 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "image" version = "0.25.10" @@ -327,7 +1528,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", ] [[package]] @@ -336,6 +1565,112 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -349,12 +1684,79 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[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 = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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" @@ -367,6 +1769,24 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -397,6 +1817,80 @@ dependencies = [ "pxfm", ] +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "num-traits" version = "0.2.19" @@ -404,6 +1898,340 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -412,6 +2240,26 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + [[package]] name = "ordered-channel" version = "1.2.0" @@ -421,6 +2269,54 @@ dependencies = [ "crossbeam-channel", ] +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "peniko" version = "0.6.1" @@ -434,19 +2330,143 @@ dependencies = [ "smallvec", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + [[package]] name = "polycool" version = "0.4.0" @@ -456,6 +2476,36 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -465,6 +2515,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "pxfm" version = "0.1.30" @@ -486,6 +2542,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -495,6 +2560,42 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + [[package]] name = "rayon" version = "1.12.0" @@ -548,6 +2649,152 @@ dependencies = [ "once_cell", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[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_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster 0.4.0", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -591,12 +2838,70 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "skrifa" version = "0.44.0" @@ -617,6 +2922,37 @@ dependencies = [ "read-fonts 0.44.0", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slidecraft" +version = "0.1.0" +dependencies = [ + "arboard", + "eframe", + "egui", + "image", + "log", + "rfd", + "serde_json", + "slidecraft-engine", + "slidecraft-ui-egui", + "winit", +] + +[[package]] +name = "slidecraft-anim" +version = "0.1.0" +dependencies = [ + "serde", + "slidecraft-geom", + "slidecraft-model", +] + [[package]] name = "slidecraft-cli" version = "0.1.0" @@ -649,7 +2985,7 @@ dependencies = [ "slidecraft-pptx", "slidecraft-render", "slidecraft-text", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -657,7 +2993,7 @@ name = "slidecraft-fonts" version = "0.1.0" dependencies = [ "epaint_default_fonts", - "harfrust", + "harfrust 0.13.3", "kurbo", "log", "skrifa 0.47.0", @@ -671,7 +3007,7 @@ dependencies = [ "serde", "serde_json", "slidecraft-model", - "thiserror", + "thiserror 2.0.21", "zip", ] @@ -701,7 +3037,7 @@ dependencies = [ "serde_json", "slidecraft-color", "slidecraft-geom", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -709,11 +3045,13 @@ name = "slidecraft-pptx" version = "0.1.0" dependencies = [ "log", - "quick-xml", + "quick-xml 0.38.4", + "serde_json", "slidecraft-color", + "slidecraft-engine", "slidecraft-geom", "slidecraft-model", - "thiserror", + "thiserror 2.0.21", "zip", ] @@ -729,8 +3067,8 @@ dependencies = [ "slidecraft-geom", "slidecraft-model", "slidecraft-text", - "vello_common", - "vello_cpu", + "vello_common 0.2.0", + "vello_cpu 0.2.0", ] [[package]] @@ -745,6 +3083,34 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "slidecraft-ui-egui" +version = "0.1.0" +dependencies = [ + "egui", + "egui_kittest", + "log", + "serde", + "serde_json", + "slidecraft-anim", + "slidecraft-color", + "slidecraft-engine", + "slidecraft-fonts", + "slidecraft-geom", + "slidecraft-model", + "slidecraft-render", + "slidecraft-text", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.16.2" @@ -754,6 +3120,93 @@ dependencies = [ "serde", ] +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "syn" version = "2.0.119" @@ -776,13 +3229,55 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -819,12 +3314,122 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-bidi" version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -843,6 +3448,38 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + [[package]] name = "vello_common" version = "0.2.0" @@ -856,7 +3493,19 @@ dependencies = [ "peniko", "png", "smallvec", - "thiserror", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo 0.2.0", + "hashbrown 0.17.1", + "vello_common 0.1.0", ] [[package]] @@ -867,13 +3516,249 @@ checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "crossbeam-channel", - "glifo", - "hashbrown", + "glifo 0.3.0", + "hashbrown 0.17.1", "ordered-channel", "png", "rayon", "thread_local", - "vello_common", + "vello_common 0.2.0", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", ] [[package]] @@ -882,6 +3767,708 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.61" @@ -954,3 +4541,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index af8de29..6fa972e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" members = ["crates/*", "apps/*"] -default-members = ["crates/*", "apps/slidecraft-cli"] +default-members = ["crates/*", "apps/slidecraft", "apps/slidecraft-cli"] [workspace.package] version = "0.1.0" @@ -20,6 +20,7 @@ slidecraft-text = { path = "crates/text" } slidecraft-render = { path = "crates/render" } slidecraft-pptx = { path = "crates/pptx" } slidecraft-format = { path = "crates/format" } +slidecraft-anim = { path = "crates/anim" } slidecraft-engine = { path = "crates/engine" } slidecraft-mcp = { path = "crates/mcp" } slidecraft-ui-egui = { path = "crates/ui-egui" } diff --git a/apps/slidecraft/Cargo.toml b/apps/slidecraft/Cargo.toml new file mode 100644 index 0000000..bdb937c --- /dev/null +++ b/apps/slidecraft/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "slidecraft" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft desktop app" +publish = false +default-run = "slidecraft" + +[lints] +workspace = true + +[dependencies] +slidecraft-engine = { workspace = true } +slidecraft-ui-egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +egui = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rfd = { workspace = true } +arboard = { workspace = true, features = ["image-data"] } +image = { workspace = true } + +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +winit = { version = "0.30", default-features = false, features = ["x11"] } diff --git a/apps/slidecraft/src/control_server.rs b/apps/slidecraft/src/control_server.rs new file mode 100644 index 0000000..17a110a --- /dev/null +++ b/apps/slidecraft/src/control_server.rs @@ -0,0 +1,62 @@ +//! Loopback JSON-lines control server: one request per line, one reply per line. +//! This is the transport the MCP server (`slidecraft-cli mcp --connect`) wraps. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; + +use serde_json::{Value, json}; +use slidecraft_ui_egui::ControlRequest; + +pub fn start(port: u16, ctx: egui::Context) -> Receiver { + let (tx, rx) = channel::(); + let listener = match TcpListener::bind(("127.0.0.1", port)) { + Ok(l) => l, + Err(e) => { + eprintln!("slidecraft: control server failed to bind 127.0.0.1:{port}: {e}"); + return rx; + } + }; + eprintln!("slidecraft: control server listening on 127.0.0.1:{port}"); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + let ctx = ctx.clone(); + std::thread::spawn(move || serve(stream, tx, ctx)); + } + }); + rx +} + +fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { + let Ok(read) = stream.try_clone() else { return }; + let mut out = stream; + for line in BufReader::new(read).lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let reply = match serde_json::from_str::(&line) { + Ok(msg) => { + let id = msg.get("id").cloned().unwrap_or(Value::Null); + let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); + let params = msg.get("params").cloned().unwrap_or(json!({})); + let (req, rrx) = ControlRequest::new(method, params); + if tx.send(req).is_err() { + break; + } + ctx.request_repaint(); + let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + if let Some(o) = r.as_object_mut() { + o.insert("id".into(), id); + } + r + } + Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}), + }; + if writeln!(out, "{reply}").is_err() { + break; + } + } +} diff --git a/apps/slidecraft/src/main.rs b/apps/slidecraft/src/main.rs new file mode 100644 index 0000000..852e15e --- /dev/null +++ b/apps/slidecraft/src/main.rs @@ -0,0 +1,184 @@ +//! SlideCraft desktop app. +//! +//! Usage: `slidecraft [--control ] [--sample] [--show] [files…]` +//! +//! `--control ` (or `SLIDECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: +//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. +//! See `slidecraft_ui_egui::control` for the methods. +#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod control_server; + +use slidecraft_engine::Session; +use slidecraft_ui_egui::{Services, SlideApp}; + +struct App(SlideApp); + +impl eframe::App for App { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.0.logic(ctx); + } + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(raw); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.0.ui(ui); + } + fn on_exit(&mut self) { + save_prefs(&self.0); + } +} + +fn prefs_path() -> Option { + let base = if cfg!(target_os = "macos") { + std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SlideCraft")) + } else if cfg!(windows) { + std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SlideCraft")) + } else { + std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) + .map(|c| c.join("slidecraft")) + }; + base.map(|b| b.join("ui.json")) +} + +fn load_prefs(app: &mut SlideApp) { + if std::env::var_os("SLIDECRAFT_NO_PREFS").is_some() { + return; + } + if let Some(p) = prefs_path() + && let Ok(bytes) = std::fs::read(&p) + && let Ok(ui) = serde_json::from_slice::(&bytes) + { + app.ui = ui; + } + if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json")) + && let Ok(bytes) = std::fs::read(&p) + && let Ok(prefs) = serde_json::from_slice::(&bytes) + { + app.session.prefs = prefs; + } +} + +fn save_prefs(app: &SlideApp) { + if std::env::var_os("SLIDECRAFT_NO_PREFS").is_some() { + return; + } + if let Some(p) = prefs_path() { + let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); + if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) { + let _ = std::fs::write(&p, bytes); + } + if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) { + let _ = std::fs::write(p.with_file_name("prefs.json"), bytes); + } + } +} + +fn services() -> Services { + Services { + pick_open: Some(Box::new(|purpose: &str| { + let d = rfd::FileDialog::new(); + let d = match purpose { + "picture" => d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"]), + "audio" => d.add_filter("Audio", &["wav", "mp3", "m4a", "aac", "flac", "ogg", "opus", "aif", "aiff"]), + "video" => d.add_filter("Video", &["mp4", "m4v", "mov", "webm"]), + _ => d + .add_filter("Presentations", &["slidecraft", "pptx", "potx", "ppsx"]) + .add_filter("SlideCraft", &["slidecraft"]) + .add_filter("PowerPoint", &["pptx", "potx", "ppsx"]) + .add_filter("Outline", &["txt", "md"]), + }; + d.pick_file().map(|p| p.to_string_lossy().to_string()) + })), + pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), + read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + clipboard_image: Some(Box::new(|| { + let mut cb = arboard::Clipboard::new().ok()?; + let img = cb.get_image().ok()?; + let rgba = image::RgbaImage::from_raw(img.width as u32, img.height as u32, img.bytes.into_owned())?; + let mut out = Vec::new(); + rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) + })), + ..Default::default() + } +} + +const APP_ID: &str = "ai.storyteller.slidecraft"; + +fn app_icon() -> Option { + let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.slidecraft.png"); + eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok() +} + +fn main() -> eframe::Result { + let mut control_port: Option = std::env::var("SLIDECRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut files = Vec::new(); + let mut sample = false; + let mut show = false; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--sample" => sample = true, + "--show" => show = true, + "--version" => { + println!("slidecraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + _ => files.push(a), + } + } + let mut options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("SlideCraft") + .with_inner_size([1440.0, 900.0]) + .with_min_inner_size([900.0, 560.0]) + .with_drag_and_drop(true) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false), + ..Default::default() + }; + options.viewport = options.viewport.with_app_id(APP_ID); + if let Some(icon) = app_icon() { + options.viewport = options.viewport.with_icon(icon); + } + #[cfg(all(unix, not(target_os = "macos")))] + if std::env::var_os("DISPLAY").is_some() && std::env::var_os("SLIDECRAFT_WAYLAND").is_none() { + options.event_loop_builder = Some(Box::new(|b| { + use winit::platform::x11::EventLoopBuilderExtX11; + b.with_x11(); + })); + } + eframe::run_native( + "SlideCraft", + options, + Box::new(move |cc| { + let mut app = SlideApp::new(Session::new(), services()); + load_prefs(&mut app); + app.integrated_titlebar = cfg!(target_os = "macos"); + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + if sample && let Err(e) = slidecraft_engine::sample::open_sample(&mut app.session) { + eprintln!("slidecraft: sample: {e}"); + } + for f in files { + if let Err(e) = app.open_path(&f) { + eprintln!("slidecraft: {f}: {e}"); + } + } + if show { + app.start_show(0, false); + } + Ok(Box::new(App(app))) + }), + ) +} diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.slidecraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.slidecraft.png new file mode 100644 index 0000000000000000000000000000000000000000..3dc9d118f82ad2ea969bd3102f0bb8ee4ac3b2ba GIT binary patch literal 4791 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 Yhqf4dc7M4G`?oYwQ`UypC|ZaA7m=3?H2?qr literal 0 HcmV?d00001 diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.slidecraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.slidecraft.png new file mode 100644 index 0000000000000000000000000000000000000000..ea75fa366d032b91ed6587925e335f77acf998c6 GIT binary patch literal 818 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(-uuz(rC1}QWNE&Rm5z%<9x#WBP} zaBlEfe_=_Ys%mF9}I| z3LY-#{Kq`WBk4%R#{{1!(WbT_!+@p>TfJ}Jo%_+3HR#IZ{PMEyo(qs6ORd8af9^vtX8Y;w9H(0kS|XG)RDG2dzX%PiuaSI*3L zojJ#6j%#+cwS!33(wKD@LL4R7ripEOao&H?*1qRm+vXbOobMJkRgVvPVQb#;=8)2+ zo2(p)EBL-9MeMMsVC>6X81vE4{JZE+cl`-#)PZ%J=wAmViz2pv^YrGtU+aE3B;RXMfgYfT?|uX3NASh4M` zTUT7;9={X5o0w7;Jz;HFeZ3(?)j;ut+(N-iF^hxp9G`zWGWUFm+RchH4;F~4z5TdH z`cdJs7M-mN+OKW({aw=2ch%Br`z@zDOFOseoilrQf0oR&l4kLiT`a38q5CU^{fpv! sp`Nv(HI@^j%Z_cyH+ETl;D5~X_+@Wp6&61U(+8zjPgg&ebxsLQ07NTZ;s5{u literal 0 HcmV?d00001 diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.slidecraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.slidecraft.png new file mode 100644 index 0000000000000000000000000000000000000000..4da5257723045b042d6d85fb239aad3b1be650ac GIT binary patch literal 1147 zcmV->1cdvEP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR917@z|H1ONa40RR917ytkO0OB=utpET6YLaJ!PHi!?(3UR5fQv+-x+%EmLbMB85Cn@9bk!obvkGF#La+;oT~ukQAXaP@ z!4yHNi-HuX3vGkc#3mIoNi&_yynAoYId3L;lg6Jca$xSfch5cFcmD2qz&eY{5 zrB6G1(0f1Zyf6E_@?V}AL@QNQN?md~__%BI#;x}8C4%bVN1lkZK3xc~H6>*Kf6-e= zijWNb7A<~nICr4@!Ob68B?Rm_()4mNFeHSM9U zNLB8V>zAf1uFVGs=-^t!l4IK+v=~kTltNXx)3Arg{v9Du%8xMn?7||xDGuSqV@DAM z3QB7_(-{rz!a9e=`eMf4@Wp=wIAqXFPeArYWYUZ)0-m zF9K-+@M)@~t}6mKToA&3cM3TFKJY^cU|=ADwKg-(t^Y^>N)=p+pkQ!g;tzav@mrKP z^&!}?2dUCh=)T^E@X^T=0_SP3vmG*))d@@i>e>b93!~7F??Vvv$muW)bG{KjcT=;G z=?Za&uKjX<(pbaSvOW*ox(>`uXYXx`HU6(oARt7-EkR^_yIHvZWrk4dsNH1mzm|>^ z@%-`Bmc_`RvHNMLa_>fxI6on(A6tV5f8MZ{g zitbW~+V!h=@9lS~<(Q1r&e9O`G+GRPzH$ZU=`t3m7M}JMM?`$7y_%f_|+d^1yo> z#EL)!gRvNsUxdJWHBJsbE85(`6Z^NS!&J?IBj9J2?`yx)Fwukd|%`>5^_35CoM_xekOpZ)kra@W9J+>- z8gkzFeeb*8djI|Yd~4mi?!I@QbMD#uo?V}FU+ZY85EIZ6002O&rmFN506;#p#AgkUxyYbsE_@!YG1I7&vS>)D24?kjJC4#}h|MNpQ z8j;B@{xpawfzAFPCe7D)d!Bw!kdSxIgf)$q>(7{doRe?dxHG+zuhLlY^k45)GMzhp zQp$Mows;J^_ce@#m>YL%`RW$Lh9632&kA3q@mG7 zE91_YQzE63G?K~hzWsLSCqVhd1@yD-1}uw<^$`xA|%d~UF=_ZaMjUZAg!85LYu z_vGGv^a z2ua!h@t(9SKVz~^mj*L`J^%;bTEwn=GiW1+#7lM zoGU4hKS$X?E#)m_2+d7~ai6}nTXB;#Z<}v|4)#f7Acv87gYli$;%>P%5FJI;?QpW_ z<+fdI8i$u|*>ho6Pp`iBhPaG7&U=VYfvc}RNbdd09BOSK@Ov*ir|mp85ZWkUSeYno zJUbl#Va9XfD=IQoZU7w#yG1Ta3CUbHG&k*UCq9TbCV-D1`GXcPj-8YKKN@An!p1$s({m{Ld;4&^nokZ-3Z5IeUkB`rUljG<`D7Xl=0b5Db6p*R&ELH?6B%?dt1qhEr+JNS2s(P8({?`}D568RoXs^)ebwQe8N1e|#Q z92b&Y4}y!iQ;Y3<;&P&Au@7;eB2pkC$zjK2c<rj9L7<%@6lhQ9m6gqW!8mFeCKb-- zmFhIX4w9i+W;>)pYw)?Vp3uUP>~FQS*egBQ<4odg+WwjFIAzz1mB`iIzV*Fhsp*63 z?Gpzhe-V99my2v@Ck8Q`W2&@S^{^Z<~> zcvde*3}|{l=>`jsKb;$+MHLFSoV34PNlv5@d~)yLTo9i)vMg}$p3>ix1XPo(nI@~j zB#?6qTVC^?-fDd!6)xJ`<)T(V1P&PO-dqYk{3i4;_l2_ocvRe@zxfvw#cO!O`C|PZ zjEo0_rVk;NHP%$1A_VZ*x6EEA{&fo%MF;Xh^|gSG%}Z-4oG2b@#O{kHH4zW#C_L}uGQgT=v{`I_1EbHe~a#Uj|p234w@`jaP64m zpBdga(|~S0wHQkk8(XmNrz6qoe-jAZc5ZyGRKBA7C`RYR>gZP;)m%f^?+a=m9!W&w zZO_te6wd8d{hW~1>ekoLJ;+`uZ^7(E6RCeuZNV8jChAzgBcwDr&Y_^wj<@rFA8 z(_0VFi#W+aqug$f0of5nD%{CiQ91evv2aH3!cfcpan*uGh|Yhy>Dm`@eLmFf6?3>~ z_qGy~N@rZGK@Q6nl}%Zo);kI+n%ss5dPqsX@|XqL78Oa>>_x(oTaeXfWXdd0i^YzP zhuDD_&-xPn{E@eI>>odKi!R#>>K$x|H}prVlq|S)apsV%+%p!_$Yy%PV0dZlz3P3{ zp7eJTD)D@iWIEv9R0|H7TUL9E8LQOw=mtMYUsR5L_vey|cmB8Qb9UQf?rOy%!XI{C z@rfmbkC=lm-}SG1)w-D_&L18%5vNbTXt3m?e^p>xcT_*bKjkK zB70jh;1I?H1BFtv``C2&SIQVoga2_X0GZq zWVEhyBTNI*83|g57(qI@;qNjtUdk<|dRv`RO#?c6;Wz!9&BKXtG_VAJ|i9DjI6sYUpzJr=(@J zs3>Wvl-sntZ1IZTs2HYsGpAVlHm!y;{1=~XB#ha8M)AIqiBcvn30ECgYeU6tV#&c} z*<}T)b=6AiXig7!7;IzGN^&hH@usj*^?03p!S16HD5^ckLtO(eV z*^%4TN)!xz*i~K^KQuwR*Jvi+>~RBpudn4VnfKo)^jjZ3XGq-I^OwHnXk`mi0gG}V zYGZ{=XgMCV7H>u!-R~gCk3I}%8ZpxC-;NF- zK-fyFkD2Bdwgwiic~ZpQ0inxKbvvz=)PV%>?j+o_O%u!hLkZ|RI3KjjVY$`PDX68f zOpT5SV@VU319_$lXP7>@Kf;C2k#Ey1(rL1SC=Qwb^Q4Q7n2`fSQEChBu*aD?y3urL zk@j;*A~zqm{PA*}B6hAI!zq4U-pLU(%9GUbz!T%3%N+3vHu?@Ma5gGlyYVJ*P&hkp zG7y*eHOna21NAWVc=K1PnR|<}sqZU#S>Ye~aK-d%t4CKkG8e~Is?JGvxhwg;N43@O z`1_LfqkXOe?B14$67vDZDrCaVS=A{tiUtq@sk0Pek#Yr^{+QN6Yvo;92Bb} zJ4!$i8wKDpQ|xMpXn>7#Y-@3)x;5%7SKYT{5kbF1W^Z^v59e!P|6ZrLc}>oa^uM~(QT6ucY?8Zk(#FPy zlgH9}`uPB#Rg2<&$EM}QVb8sOJEmL2b0McDCxN|a;cIKfDd49vM#}4ZL;m~aWpY0n z+^2oMD)Y~u2<^n$JTei^j0-uql6yD!5R>70F!Na}KP^QJ_Zku#`qnb>y5q8Lx?h_y z_~{h#P}1b)5Y0&ccT(w;VL1{u1kw?&0Ow)cXpYukc=!R!MORPy zU`K~+XplDMNB#hw`W;?*RmPAl^1uV6B~MfMwz5gJwWXtI=68y)j7ZF*uvR;my z;0cm=Oy$%z-)iKJnH68Rl0AO?D)<2z$s~7O%AK7^AaAU-sf_I8GXODGQ)+9jC|OMu zb{=z@rDF`xpLRv3N^dz_UloL}imv$YUwx@CxslHl_t#R4ry&yXe>qJ3^By-yJ)ihB zSy+YFPji~UH5Iwv({Jv$w=;;`i?a&woIY5)Mz?I#uqUv`ru4AbXSj>iZ(6ueikU2tPtcg;mU$mwXW z)RdBzn&+OgL6UqT9?6$$TDE`n6zG0v64%KJ&KN}g{{66*A=pQgV+VF}x~9f?TR8pa zt^m1iRAhkRb&>t zDEhG1Sd8{JurFS&Ep3*s@vZYltR+Laj0497+_j_}VcNCwm~eeZJ4|CSbeYHB6|TdE z^X)=Zg@Gj>ji$;nEQ$wc23ZK;I_#o|fWwJi%I(N=OIB&WHkz#m+-)0KlQY~%^v*A@ zQTe)s^%@+3W)5AV2g$;Vek~R4J4|bo`PVtw_6@9vte$UoM$-AsF_MzT>5xHtx`S=f zcXtt4cWp4Q3p8ho2rb=bS{4>2x-qHa`P*kc{=So`tM`!PQV@Q#(J*W zgYQTT+h%|9Tipl2V<$d@-v5-qy%hna8rY1~I9cF$(tU|Z^ zre{VA#P#W0Ckks}E8d&*Fr&~Duja${2kSN#{D&%uvK(~b%_f&V{fD;^@@md}WtuS* z*oCHInBxAMXrQO*Fv`YGa_GWi({jN+KM_sG9`dN(u=S zjK{>_*#$T%@w!4jl;1WsH9o1^$3YlGk0y?88QhNT+7lO~;LA-@fn3id&1qbEZAZFh z>g>0x7i&0xEF$!M!$Rwf901qa>)~}H2{cYRgdvMDvmYDi?gw#bfPYgK0806H>JboD zHf+cvdPK<)u?5uuK8)a)!oly!vD{aA&}8@tgbVn+3F2dp=)>0mSQSU2)R=?7cM)61 z2eg6q3{?i zGpRfHR&%Yuohl<_QdbB}fgcIr7No3<%D%s8H2|`nB<=uL7ySi~q+It9hS zST*KbtV~BEY^JK_TLS;<$ae+N;(N`X(z$HozX#%l0~T%scjG)fr( zCZjb~)&L)IVs#nL_*;PjKOUPth}6erKvuM|T#U?zXc!qSR!NSI_XVLp^0D08KqW#o zW)bWZMn{F7QN+q&x!1?B)Jbqh>=X(M?SA}!GQs}QXJ4jc-OPSGjJ3`_EC4s&QOS{2 zEYx`yvWU3|`_OROK~{AD1Y3|e2kY3#3>U1}${1jklft^2@nEb$^#=h(yp-+-wN;Ij zPh!h;g38o`>L~`|BlDYuGr^Kibz=Y3nE0}A$JjD8d}BbozVTd5rzDIkvkXjH|;ksB*VKu zXw6PsU!lO5@0>AR<91P$*!A$_{f~kd-D+&%vOf`uPE&{8hma5v{FmA(f=K}M$4l%d zWW0bsP-UjTenQo1VcmaeHUV7bZ-n7HIP9NgSUY@xk&$A#0c$vw%n_>iKP&;>Q1(YA zTq%O}ezm9z1}NmQby=;1!3;6a0`to22EbU#xuSgL>-T4GJ*ZdL{C7iiDWC_14L#FG z9u)Xl0Je~SwmnZww5~k!_tc>)gOW^hS`U64`vvnnd?&yKgK2C&7DTym1G= zV7HLy1Mde7>N`Yh3Nw5cE$9^EYX;PNgwAh0Y8bnLg5bggjPzzH#%rO4e}Ky6p{IeE zRiAf2An%)rA#w0X&U4n!qIxQ_;Wt4|{~0F5B*h|SSt7=m5U#adnUS7i$B^c_;Gtey z+7VecJ&6g)b)EmJcDs}7FqpLeI7wq??+1NA4k3=)u{=CiE`Mb3EHBsV{L%S9C|!qm zA$QjFh5o;iitD>4Newknot<8@h7&%@Ya}C83O;leC7k5faJxBc$FQF}Q}HINxp)Hv zAl8;KrNfui?v58#p@kz zMSuGUCHrBdcR?EYkNeg?<=Lb1x-hEDaPa55<_;gOx|T5kHUoUv&Yq-{2h8@=Mbx8T z#(Sr8r+7AHNBKKvtlnv?7?9J5H|anA`Jc}l-OE^ zv#v#HLAQ>tdDCsrYw%OWe2e4*X}q6PU%$6beb|pUQb)JO0y`Q#xuuU|IIdr7RiKS5 zIWsKA`E`VQ%z&sKH)!+LR(;4;M*WZ$om=D7L`oS&IUT6WB7x`6S<3rF{*k`c1lKXy zh_-8;a6c02=N@Sdk=swec~9r+FV+ma6;M0XGtkVBsiGDq#J)tXOIvSEDgXYAR+H2W zPiy3yzvW5!9Yo;E4giGfk5vY{sR#EQFS6OjO&7dQeAOS>R8>}%GMpwjZcc=s^&h;- z<<6yB5l~U7S_oFY`p72W;^Nz;r|z`ctoH4hMptR%+h7ljz6eSS_~`+O_N8yxh`8HM zPa_xIX~hD7xME0$dq_oncUYrlF|kIz&O3v)-XGm@3o;5eaKDQ{x=QGkS2xqWe~I)T z>%>7Pddv_s$3;#sooTGS`R0a-JH)x=WMGb*cU1yWcG-a#KZ zQ3Tsj@W2b>W!WPj2359@uIw`g@D+{({ZpZDC zzv4pr_{iE(C?oR9Bd`dK(3QN8IEH~9ZUfKmS!O~9dowQw+bVB0LEep zU($8+z}CEV408;16id-by=u&ikfri3BV`9cCTrY)CT^gvxIE1d60UzR_7Nvw75xu0 zs5rfgC{*}s7hjd0?Gf}}(10g|x&vkRz66-Bexcskp&ddleJF%QJ+T2Sgi4kWd0IQA z2TXmLmcYRzTX!!weRc0yhp)Q&yWGa!W15(iQhP4Mot zVhbTe^E%VOMa>0$GyHs8LW_eiTg&jaf zC3YL#aO|1^`sVb+bbrXV?(vPVMa)tlJi4(!SeV`!9}qj*xVBiBbAr|NJ^p`8GK6-w z_;S(uN8>bLsHJ8zkm31PmfX<^8dU`3nIa$y#g3Y0t6!Uw?CJhZ$ZD7>=hy%ocMzo9 z{CcS1INL`Fua;u~QKC58@O_TJ_)T{dQ!Xx}(~EizY17z=s1{ZFz)!MRRCG}Et1IYF zbNS8t9`CC>cP&%S=Y-_x@4OBge{P~uGy;8$7!8E?az1;mJnk>dYLY7%{>Y~ENf@X1 z#W1O5pPI@O+ybryQw-FPkNf?)chdXX^3zbGB+KMCr>cu#L7V#tDg*6dEq?{<%&!dn zt>&`?P^YQiQ#M5|BRm~~R-4WTH`;O4QYZfq#N=7gdFck-K4>DLOUTQ8$CNkDqB)Sj z?zSWJ{`ZXBOWe&Q6I76U-epC9Vu}S0T%@^hxr)>wYg)u)%0;>83#V3?Rn3Li=jeM0 zo6{F7)&b_OxYDn-p(0LK?h+mt9&wq!r`sbr*N%^4Hy2wJ(X6$@f3v8N+Gm?yuF zrT*D!$Osm=?|EWdW(>Tu-c+B%gnqO}CTij1VTYY1M~r+sUwS$D(KHpv@KE~((=*yZ zb+x-f^^=L)(jeEAh^lY@wi@IyMp`m;ADV!k-#+G@r!(#4EmiL7nJ#G4>Wra3NRwZA zx(xi^SSTrHG|uDQoeQHwT7+rOX=ZP6BsX_vP>r%?e9>jN0N&7(kJEc24kJ!#S$f>y zDtjm%rp?LbDG91ND!J5hePxp$ziUP8?MuWH>%twm6#uX`53Zx=Bt@!FqKc!%V9)cK z?Ud^(WeHBEmk}_k-}uX{ZF;>r<|NO#Ngon$yvq1tqw_Q{EyiE$=VPv9EX&8Uq$ zhe%a!0T4p)ro45;D56F04!$YP05%(kkr9C=I3}B1Pb&#$r%ywKGmKA{@9HbX-g&i7 z;Khd|fT*Qx#+OJ3G2IeWymDt-oqs!hilXKv$RR5E9y2D>VJ8+9(b+%Y%atHSQq{K zlkEDVjuBt<{9q--FY$4pDJp=A(3&fDkwi+`PF?Pq4YbZ8X9}S}5|eQ|TEc>86=YE! zLjJhR>eV4H1ptx=U-a|a`M~8+UpaR)SX~NPyrbjFMKp46*MH|3yYvgif74uqEIl~9 zkJic!H#`3t4No}WdBc1_h)fHVzpIio zUhaJV@kUDId3o>svLchA!a}ja{ zU*S1U9oRfG>aN%T3%EZ=mhGp0jxw*Rf^K?MbV*R%y2HR#_2>R5{7%ta@l*Y_GHuZz zR!V>-emY7D>0-fSqqUkc<6QfGdWsU%M)p0$(qa%pZk97?bAF=T`^i2ONWLFIH_>3&m+#y26!d}y>r$F0-+y*(b*1FKDx2bP4ga|{h@t08yD@R2Xv0AkDPxgm z;pi^(O7o@EwRB7WhRUqR<=%$GyqPNm5bz1;@`7;ULsJ_F)X`Y4pBIg_h$ERxJ= z49B3?LtcrEA&sX})%H1!C0ux`@6gf|;1@xAO^uvRH$ryKwjd)Kvadl6WlQ_#V zGX8A?!NwF>wJb|?VFo?sj1?gi#nKYc?Lv7Mb<}L9%DH{FzG82JRNG|dMADNn%hz_~ z*z&07RAS#-8M66uQ0gk{IK~XuJ5q4irJ?9F!1p@ZlzVGT!&MU%zOVd7Kl1*2WZ^O&>?oMslGyN+UY41ZObQte@l>~R6q^uOxz7d^4;?=7{C|H< zdB#2z5}G~g9_KWxkjdMR| z>>Tuh?UA*1;W?HIqFVchT51RgHemh_j{%lL3}xV$ft%^Q=h%OJ0JXPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91AfN*P1ONa40RR91AOHXW0IY^$^8f$^ElET{R9FecS6gUYRTN$4KITpy zlQxMaA8DGAl!PQqgrf9EN(G^yi1<;z6iTI3n$}RQ_~CSlWAif4dyj7nLGEm_nEoL_0A+SgJ?Dh=gv9ntiA5u`#dhN!zK#( zpYCa@uhYdrDG|wJM5qv^)IMF8%R(S}FC(w-8Hg|V=lKPQ<(}q~x+Y#RG_>gcQUB}` ze`h3MJDAEivOhEs8>DH}leG;OvJcwQeDgb1hB%eZ0BL6f<6{3+Yia_f4y@YpLd|`# zUeO1K2@RKZWv=-|l_^do(o)Iex2Mo~w7SSLTjHQpsu!XtfPIj!Q6Tm%GpPvtb!JbU zSQo#V8k*7sBC(iSOd?4$6rfC>UO2}w$oZ@df`dvvNDSgKAiH;+pT4IeprvrgGDTC; zmar&HAKXmgnjS&J{q=Ah6)7)gSpge{2~=`NcvU82uVSa|Y{U!&uz?0G>U1648<05DvHC z%d0~a5h|=LY)b(yzN)fvGyEh zdl|i_kE13O%C4Z?3%D?EyS)Ie{KPWE>}{Gu>w!8r_7*-${d(WAz6ehHyq_D zN`PbGo8-ETyO+`&*FPFg-^I}+{!B>l?#@^2pXRAb$=?2P4Sbd7UyT(4&$U~HZVw8} zmr}ZW#wGdW4kdtZr}RN`&eX*qSF^uX_(gezO)NQ>T&C+4bucDU&r*P&=WXSX%V0G` zebn+f#u%?L5BcB~bFPMb@)-9iE9Jw)@}$6>=WjU$T=9O}>ceD1KyJd$eVTa{|DPYH dUcWo?{5Rg`5s;PXAT9s^002ovPDHLkV1k;@nu!1a literal 0 HcmV?d00001 diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.slidecraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.slidecraft.png new file mode 100644 index 0000000000000000000000000000000000000000..970fb822c29b13ff2c400ce36515473a079d7767 GIT binary patch literal 2057 zcmV+k2=@1hP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NJb4f%&RA>d&T5D_+RTMsVrn|F` zE-h^(rBG6c1W+T0JQ71Rpe7=3Fr+9NFwqd;m;Nx7L?Ve26a6JLY9K;l)M$hXMhT%p z6D9Ex69tv{2a|$@+CsZ+cXxJnXFT5>W_Ol-bhgw&Pf}*~zRvfZbI(0@?uBTai|~ry z+R_vlsuESYq5Hk|8A&_MrzOMvR=+s(cfw2mk_5W9-&Gk=wACglYb~^$T&t;E4{_camT> z_&7k4mbHGuzRorWTZRAvHXyX0{HikBZF$*|{lr1#^cj;UFB&rL+=j$-N~X;V2}@t- z1a^gkCZq;dX^NN~Pi0N7FlIeXboR1NLmb6<^Udiz%1>S|c*-cWpk5A5M_@?OE_KHr_BABQ6J&GExDWpX{G)9j8h5LH#_a@Q4FzN(on z^d@QEB)>E!RHxX6GAA)+Bs=K=WLS}=nzU=e!2>$GtTG&;nu*n3Eg^H&xt-@WE?fX%n)n>*dA1n;v-CYm*AJ9)Oq&w^s*<|47p8Ry$Zjg ze;^Q`>Z%EH%)+hgQb!k_h16K4<C)vc>5<|3xa;+V zPHR>_NguSnO}egUsx-~sS_vK>-bp5nOr4h(&jc_STsysK8ij&EQg}*v7S%_(VbJ8M zQ_19a5)9HZ$>;OY*C*QP<4=zeh`jLpW?H;xKBZFLW6d)G?1KRO@WFRI~Exn1dB^^=dbrJk>$-?!V8oin0m7QiTM> z0s)PUo+YUBe8yhTQaBqic=@Md0kGRlYn2E}~HVNHN?}zw!t`m6;~d^%qgR zkC8JW>3d$|!}-(dmXLYuTs{E|ta3M*QEw1{CvKB?kCxj6a5%N&opd17z^Z z;lu#1N?b~DPG*TjoQpXRaIDB}H%#cXM>bAzlH$72lJW=uygxwJ!ZkJlxm)M5${Nxt zRNB1hDeCKwawb;f*IVP1$@W&_f#tk<^)g|6;Bs24Z5%ZcB?-W)NKi;Dc*?VuKqXl# zUqVgRh$)?dOm5HaS7bYjZ{UQj`EX;uvEv7Ng5w#SGYtpE z!_aw6#(g4_1aPe*6dV2SXCz*A7$dMcmw=fdRut)e~^{qDS5$h+-Z^_fEy3KVrpS?9ARU6 zx?;kaPeWLtl}$cQI9U%kgF8iT%R6;^X(#()3@;)bjFnl4mPz}w*RFY5`*t+G8Q}}H z7~j+&2Mi~e->}R`1UD{T2mpMDy**%9d#m?%zUtPMrz;j&x nR|7W@yx*z++2dxMu=v>++~wEPvZ00000NkvXXu0mjfY-Xx9 literal 0 HcmV?d00001 diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.slidecraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.slidecraft.png new file mode 100644 index 0000000000000000000000000000000000000000..1602e0a27463c349fcf64fc8f2247de17955d5f9 GIT binary patch literal 20164 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 literal 0 HcmV?d00001 diff --git a/assets/app-icon/icon-source.slidecraft b/assets/app-icon/icon-source.slidecraft new file mode 100644 index 0000000000000000000000000000000000000000..08a82ede4007c8ae3da3d2879b5d1a002b1b6267 GIT binary patch literal 4983 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 Z$d=sVb&h{8HG}^xn7d?7-0W3B{|oW~8TJ4G literal 0 HcmV?d00001 diff --git a/crates/anim/Cargo.toml b/crates/anim/Cargo.toml new file mode 100644 index 0000000..53c2805 --- /dev/null +++ b/crates/anim/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "slidecraft-anim" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft slide show timing: animation sequences, per-shape animation state over time, and transition frame layouts" + +[lints] +workspace = true + +[dependencies] +slidecraft-model = { workspace = true } +slidecraft-geom = { workspace = true } +serde = { workspace = true } diff --git a/crates/anim/src/easing.rs b/crates/anim/src/easing.rs new file mode 100644 index 0000000..b31066a --- /dev/null +++ b/crates/anim/src/easing.rs @@ -0,0 +1,75 @@ +//! Time easing: acceleration / deceleration fractions and bounce. + +use crate::clampf; + +/// Map linear progress `p` (0..1) through PowerPoint-style easing: `accel` and `decel` are the +/// fractions of the duration spent speeding up and slowing down (velocity ramps linearly); +/// `bounce` (0..1) makes the last part of the motion bounce against the end. +pub fn ease(p: f64, accel: f64, decel: f64, bounce: f64) -> f64 { + let p = clampf(p, 0.0, 1.0, 0.0); + let mut a = clampf(accel, 0.0, 1.0, 0.0); + let mut d = clampf(decel, 0.0, 1.0, 0.0); + if a + d > 1.0 { + let s = a + d; + a /= s; + d /= s; + } + let v = 2.0 / (2.0 - a - d); + let mut y = if a > 0.0 && p < a { + v * p * p / (2.0 * a) + } else if d > 0.0 && p > 1.0 - d { + let q = 1.0 - p; + 1.0 - v * q * q / (2.0 * d) + } else { + v * a / 2.0 + v * (p - a) + }; + let b = clampf(bounce, 0.0, 1.0, 0.0); + if b > 0.0 { + // Reach the end early, then bounce back with decaying hops over the last `b` fraction. + let hit = 1.0 - b; + if p < hit { + let q = if hit > 0.0 { p / hit } else { 1.0 }; + y = q * q; + } else { + let s = (p - hit) / b; + y = 1.0 - bounce_hops(s) * 0.25; + } + } + clampf(y, -1.0, 2.0, 0.0) +} + +/// Decaying hops for s in 0..1: 0 at the ends of each hop, peaks shrinking. +fn bounce_hops(s: f64) -> f64 { + let s = clampf(s, 0.0, 1.0, 1.0); + let hops = 3.0; + let k = (s * hops).floor().min(hops - 1.0); + let local = s * hops - k; + let amp = 0.5f64.powf(k); + amp * (local * std::f64::consts::PI).sin() +} + +/// Classic bounce-out: falls to 1 and bounces with decaying height. +pub(crate) fn bounce_out(p: f64) -> f64 { + let p = clampf(p, 0.0, 1.0, 0.0); + let n = 7.5625; + let d = 2.75; + let y = if p < 1.0 / d { + n * p * p + } else if p < 2.0 / d { + let q = p - 1.5 / d; + n * q * q + 0.75 + } else if p < 2.5 / d { + let q = p - 2.25 / d; + n * q * q + 0.9375 + } else { + let q = p - 2.625 / d; + n * q * q + 0.984375 + }; + y.min(1.0) +} + +/// Smooth ease-in-out (cubic smoothstep). +pub(crate) fn smooth(p: f64) -> f64 { + let p = clampf(p, 0.0, 1.0, 0.0); + p * p * (3.0 - 2.0 * p) +} diff --git a/crates/anim/src/effects.rs b/crates/anim/src/effects.rs new file mode 100644 index 0000000..a795635 --- /dev/null +++ b/crates/anim/src/effects.rs @@ -0,0 +1,317 @@ +//! Evaluation of one animation effect at a progress value. + +use std::f64::consts::PI; + +use slidecraft_model::{AnimClass, Rgba, Xfrm}; + +use crate::AnimState; +use crate::easing::{bounce_out, ease, smooth}; +use crate::path::{MotionPath, default_path}; + +/// Everything an effect needs besides its progress. +#[derive(Clone, Debug)] +pub(crate) struct EffectSpec { + pub class: AnimClass, + /// Base effect id (exit ids mapped to their entrance counterpart). + pub effect: String, + pub option: String, + pub accel: f64, + pub decel: f64, + pub bounce: f64, + pub amount: Option, + pub color: Option, + pub path: Option, + /// Shape box in points (sanitized). + pub xf: Xfrm, + pub slide_w: f64, + pub slide_h: f64, +} + +/// Map an exit effect id to the entrance effect it mirrors. +pub(crate) fn exit_base(effect: &str) -> &str { + match effect { + "disappear" => "appear", + "fadeOut" => "fade", + "flyOut" => "fly", + "floatOut" => "float", + "splitOut" => "split", + "wipeOut" => "wipe", + "shapeOut" => "shape", + "wheelOut" => "wheel", + "randomBarsOut" => "randomBars", + "shrinkTurn" => "grow", + "zoomOut" => "zoom", + "swivelOut" => "swivel", + "bounceOut" => "bounce", + "dissolveOut" => "dissolve", + other => other, + } +} + +/// Emphasis effects whose end state persists after they finish (PowerPoint keeps the spun angle, +/// the new size, the new colour and transparency). +pub(crate) fn emphasis_persists(effect: &str) -> bool { + matches!( + effect, + "spin" + | "growShrink" + | "desaturate" + | "darken" + | "lighten" + | "transparency" + | "objectColor" + | "complementaryColor" + | "lineColor" + | "fillColor" + | "fontColor" + ) +} + +fn uses_default_smoothing(effect: &str) -> bool { + matches!(effect, "fly" | "float" | "rise" | "peek" | "lines" | "arcs" | "turns" | "shapes" | "loops" | "customPath") +} + +impl EffectSpec { + /// Eased progress. + pub fn eased(&self, p: f64) -> f64 { + if self.accel <= 0.0 && self.decel <= 0.0 && self.bounce <= 0.0 && uses_default_smoothing(&self.effect) { + smooth(p) + } else { + ease(p, self.accel, self.decel, self.bounce) + } + } + + /// The state delta at linear progress `p` (0..1) while the effect plays. `cur` is the shape's + /// current fill colour (for colour emphasis). + pub fn eval(&self, p: f64, cur: Rgba) -> AnimState { + let p = crate::clampf(p, 0.0, 1.0, 0.0); + let s = match self.class { + AnimClass::Entrance => self.entrance(p, false), + AnimClass::Exit => self.entrance(1.0 - p, true), + AnimClass::Emphasis => self.emphasis(p, cur), + AnimClass::Path => self.motion(p), + AnimClass::Media => AnimState::default(), + }; + s.sanitized() + } + + /// The state the effect leaves behind once done. + pub fn end_state(&self, cur: Rgba) -> AnimState { + match self.class { + AnimClass::Entrance | AnimClass::Media => AnimState::default(), + AnimClass::Exit => AnimState::hidden(), + AnimClass::Emphasis if emphasis_persists(&self.effect) => self.emphasis(1.0, cur).sanitized(), + AnimClass::Emphasis => AnimState::default(), + AnimClass::Path => self.motion(1.0).sanitized(), + } + } + + fn entrance(&self, p: f64, exit: bool) -> AnimState { + let e = self.eased(p); + let q = 1.0 - e; + let o = self.option.as_str(); + let Xfrm { x, y, w, h, .. } = self.xf; + let (sw, sh) = (self.slide_w, self.slide_h); + let mut st = AnimState::default(); + let center_clip = |f: f64, horz: bool, vert: bool| { + let f = f.clamp(0.0, 1.0) / 2.0; + let (l, r) = if horz { (0.5 - f, 0.5 + f) } else { (0.0, 1.0) }; + let (t, b) = if vert { (0.5 - f, 0.5 + f) } else { (0.0, 1.0) }; + Some([l, t, r, b]) + }; + match self.effect.as_str() { + "appear" => st.visible = p > 0.0, + "fade" | "dissolve" | "randomBars" | "blinds" | "checkerboard" | "wheel" => st.opacity = e, + "fly" => { + let o = if o.is_empty() { "b" } else { o }; + if o.contains('l') { + st.offset_x = -(x + w) * q; + } + if o.contains('r') { + st.offset_x = (sw - x) * q; + } + if o.contains('t') { + st.offset_y = -(y + h) * q; + } + if o.contains('b') { + st.offset_y = (sh - y) * q; + } + } + "float" | "rise" => { + // Float Up enters from below and leaves upward; Float Down the reverse. + let down = o == "d"; + let dir = if down { -1.0 } else { 1.0 }; + let dir = if exit { -dir } else { dir }; + st.offset_y = dir * 0.1 * sh * q; + st.opacity = e; + } + "split" => { + let horz = o.starts_with("horz"); + // "In" closes from the edges: approximated with the centre reveal plus a fade. + st.clip = if horz { center_clip(e, false, true) } else { center_clip(e, true, false) }; + if o.ends_with("In") { + st.opacity = e; + } + } + "wipe" => { + st.clip = Some(match o { + "l" => [0.0, 0.0, e, 1.0], + "t" => [0.0, 0.0, 1.0, e], + "r" => [1.0 - e, 0.0, 1.0, 1.0], + _ => [0.0, 1.0 - e, 1.0, 1.0], + }); + } + "shape" | "circle" | "box" | "diamond" | "expandClip" => { + let round = matches!(self.effect.as_str(), "circle" | "diamond") + || o.starts_with("circle") + || o.starts_with("diamond") + || o.starts_with("plus"); + let f = if round { (e * std::f64::consts::SQRT_2).min(1.0) } else { e }; + st.clip = center_clip(f, true, true); + if o.ends_with("In") || o == "in" { + st.opacity = e; + } + } + "grow" => { + st.scale_x = e; + st.scale_y = e; + st.rotate = -90.0 * q; + st.opacity = e; + } + "zoom" => { + let s = if o == "out" { 1.0 + 3.0 * q } else { e }; + st.scale_x = s; + st.scale_y = s; + st.opacity = e; + } + "swivel" => { + let s = e * (q * 2.5 * PI).cos(); + if o == "vert" { + st.scale_y = s; + } else { + st.scale_x = s; + } + st.opacity = e.min(1.0); + } + "bounce" => { + let b = bounce_out(p); + st.offset_y = -(0.25 * sh) * (1.0 - b); + st.offset_x = -0.1 * sw * (1.0 - p); + st.opacity = (p * 4.0).min(1.0); + } + "peek" => match o { + "l" => { + st.offset_x = -w * q; + st.clip = Some([q, 0.0, 1.0, 1.0]); + } + "r" => { + st.offset_x = w * q; + st.clip = Some([0.0, 0.0, e, 1.0]); + } + "t" => { + st.offset_y = -h * q; + st.clip = Some([0.0, q, 1.0, 1.0]); + } + _ => { + st.offset_y = h * q; + st.clip = Some([0.0, 0.0, 1.0, e]); + } + }, + "strips" => { + st.clip = Some(match o { + "ru" => [0.0, q, e, 1.0], + "ld" => [q, 0.0, 1.0, e], + "rd" => [0.0, 0.0, e, e], + _ => [q, q, 1.0, 1.0], + }); + } + "expand" => { + st.scale_x = 0.3 + 0.7 * e; + st.opacity = e; + } + "spinner" => { + st.rotate = 360.0 * q; + st.scale_x = 0.5 + 0.5 * e; + st.scale_y = 0.5 + 0.5 * e; + st.opacity = e; + } + _ => st.opacity = e, + } + st + } + + fn emphasis(&self, p: f64, cur: Rgba) -> AnimState { + let e = self.eased(p); + let bump = (p * PI).sin(); + let mut st = AnimState::default(); + let target = self.color.unwrap_or(Rgba::rgb(0xC0, 0x00, 0x00)); + match self.effect.as_str() { + "pulse" => { + st.scale_x = 1.0 + 0.1 * bump; + st.scale_y = st.scale_x; + } + "colorPulse" => st.tint = Some(cur.lerp(target, bump)), + "teeter" => st.rotate = 8.0 * (p * 4.0 * PI).sin() * (1.0 - p * 0.5), + "spin" => { + let amt = self.amount.filter(|a| a.is_finite()).unwrap_or(360.0).clamp(-36_000.0, 36_000.0); + let sign = if self.option == "counterClockwise" { -1.0 } else { 1.0 }; + st.rotate = sign * amt * e; + } + "growShrink" => { + let amt = self.amount.filter(|a| a.is_finite() && *a > 0.0).unwrap_or(1.5).clamp(0.0, 100.0); + let s = 1.0 + (amt - 1.0) * e; + match self.option.as_str() { + "horz" => st.scale_x = s, + "vert" => st.scale_y = s, + _ => { + st.scale_x = s; + st.scale_y = s; + } + } + } + "desaturate" => { + let l = (0.299 * cur.r as f64 + 0.587 * cur.g as f64 + 0.114 * cur.b as f64).round().clamp(0.0, 255.0) as u8; + st.tint = Some(cur.lerp(Rgba::rgba(l, l, l, cur.a), e)); + } + "darken" => st.tint = Some(cur.lerp(Rgba::rgba(0, 0, 0, cur.a), 0.4 * e)), + "lighten" => st.tint = Some(cur.lerp(Rgba::rgba(255, 255, 255, cur.a), 0.4 * e)), + "transparency" => { + // `amount` is the transparency (0.5 = 50 %). + let a = self.amount.filter(|a| a.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0); + st.opacity = 1.0 - a * e; + } + "objectColor" | "fillColor" | "lineColor" | "fontColor" => st.tint = Some(cur.lerp(target, e)), + "complementaryColor" => st.tint = Some(cur.lerp(Rgba::rgba(255 - cur.r, 255 - cur.g, 255 - cur.b, cur.a), e)), + "underline" | "boldFlash" => { + st.scale_x = 1.0 + 0.04 * bump; + st.scale_y = st.scale_x; + if self.effect == "boldFlash" { + st.tint = Some(cur.lerp(Rgba::rgba(0, 0, 0, cur.a), 0.3 * bump)); + } + } + "wave" => { + st.offset_y = -0.02 * self.slide_h * bump; + st.scale_y = 1.0 + 0.05 * bump; + } + _ => {} + } + st + } + + fn motion(&self, p: f64) -> AnimState { + let e = self.eased(p); + let (fx, fy) = match &self.path { + Some(mp) => mp.at(e), + None => (0.0, 0.0), + }; + AnimState { offset_x: fx * self.slide_w, offset_y: fy * self.slide_h, ..Default::default() } + } +} + +/// The path for a motion-path animation: its own, or the preset default. +pub(crate) fn motion_path(effect: &str, option: &str, own: Option<&str>) -> MotionPath { + match own { + Some(s) if !s.trim().is_empty() => MotionPath::parse(s), + _ => MotionPath::parse(&default_path(effect, option)), + } +} diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs new file mode 100644 index 0000000..a5a8744 --- /dev/null +++ b/crates/anim/src/lib.rs @@ -0,0 +1,111 @@ +//! Slide show timing for SlideCraft. +//! +//! - [`Timeline`]: a slide's animation list grouped into click steps, with per-shape (and +//! per-paragraph) [`AnimState`] at any point of the show. +//! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images. +//! - [`morph_pairs`] / [`morph_xfrm`]: Morph transition shape matching and interpolation. +//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings). +//! +//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or +//! negative times) and never panics. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod easing; +mod effects; +mod morph; +mod path; +mod show; +mod timeline; +mod transition; + +#[cfg(test)] +mod tests; + +use serde::{Deserialize, Serialize}; +pub use slidecraft_model::Rgba; + +pub use easing::ease; +pub use morph::{morph_pairs, morph_xfrm}; +pub use path::{MotionPath, default_path}; +pub use show::{ShowAction, ShowState, show_order}; +pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay}; +pub use transition::{Layer, Source, transition_layers}; + +/// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show. +/// +/// Same meaning as the renderer's `ShapeState`: translation in points, scale and rotation (degrees) +/// around the shape's centre, a reveal clip in shape-local unit space `[l, t, r, b]`, and a fill +/// colour override. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnimState { + pub visible: bool, + pub opacity: f64, + pub offset_x: f64, + pub offset_y: f64, + pub scale_x: f64, + pub scale_y: f64, + pub rotate: f64, + pub clip: Option<[f64; 4]>, + pub tint: Option, +} + +impl Default for AnimState { + fn default() -> Self { + AnimState { visible: true, opacity: 1.0, offset_x: 0.0, offset_y: 0.0, scale_x: 1.0, scale_y: 1.0, rotate: 0.0, clip: None, tint: None } + } +} + +impl AnimState { + /// A hidden state. + pub fn hidden() -> Self { + AnimState { visible: false, ..Default::default() } + } + /// Is this the identity (nothing to apply)? + pub fn is_identity(&self) -> bool { + *self == AnimState::default() + } + /// Apply `d` on top of `self`: opacities and scales multiply, offsets and rotations add, clips + /// intersect, a tint replaces. + pub fn compose(&self, d: &AnimState) -> AnimState { + let clip = match (self.clip, d.clip) { + (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]), + (a, None) => a, + (None, b) => b, + }; + AnimState { + visible: self.visible && d.visible, + opacity: self.opacity * d.opacity, + offset_x: self.offset_x + d.offset_x, + offset_y: self.offset_y + d.offset_y, + scale_x: self.scale_x * d.scale_x, + scale_y: self.scale_y * d.scale_y, + rotate: self.rotate + d.rotate, + clip, + tint: d.tint.or(self.tint), + } + } + /// Replace any non-finite number with its neutral value and clamp opacity and clip. + pub fn sanitized(mut self) -> AnimState { + let f = |v: f64, d: f64| if v.is_finite() { v } else { d }; + self.opacity = f(self.opacity, 1.0).clamp(0.0, 1.0); + self.offset_x = f(self.offset_x, 0.0); + self.offset_y = f(self.offset_y, 0.0); + self.scale_x = f(self.scale_x, 1.0); + self.scale_y = f(self.scale_y, 1.0); + self.rotate = f(self.rotate, 0.0); + self.clip = self.clip.map(|c| { + let l = f(c[0], 0.0).clamp(0.0, 1.0); + let t = f(c[1], 0.0).clamp(0.0, 1.0); + let r = f(c[2], 1.0).clamp(0.0, 1.0); + let b = f(c[3], 1.0).clamp(0.0, 1.0); + [l, t, r.max(l), b.max(t)] + }); + self + } +} + +/// Clamp a hostile number into `[lo, hi]`, mapping NaN to `nan`. +pub(crate) fn clampf(v: f64, lo: f64, hi: f64, nan: f64) -> f64 { + if v.is_nan() { nan } else { v.clamp(lo, hi) } +} diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs new file mode 100644 index 0000000..6689153 --- /dev/null +++ b/crates/anim/src/morph.rs @@ -0,0 +1,93 @@ +//! Morph: matching shapes between two slides and interpolating their boxes. + +use slidecraft_model::{Shape, ShapeId, Slide, Xfrm}; + +use crate::clampf; + +fn text_of(s: &Shape) -> String { + s.text.as_ref().map(|t| t.text()).unwrap_or_default() +} + +/// Pairs of (shape on `a`, shape on `b`) that Morph animates from one to the other: first by +/// `!!name` (forced match), then by exact name, then by the same kind and text, then by the same +/// kind and preset geometry. Each shape is used once; top-level shapes only (groups move whole). +pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> { + let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new(); + let mut used_a = vec![false; a.shapes.len()]; + let mut used_b = vec![false; b.shapes.len()]; + let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [ + &|x, y| x.name.starts_with("!!") && x.name == y.name, + &|x, y| !x.name.is_empty() && x.name == y.name, + &|x, y| { + if x.kind_name() != y.kind_name() { + return false; + } + let tx = text_of(x); + !tx.trim().is_empty() && tx == text_of(y) + }, + &|x, y| { + x.kind_name() == y.kind_name() + && x.geom.preset_name().is_some() + && x.geom.preset_name() == y.geom.preset_name() + && text_of(x).trim().is_empty() == text_of(y).trim().is_empty() + }, + ]; + for pass in passes { + for (i, sa) in a.shapes.iter().enumerate() { + if used_a.get(i).copied().unwrap_or(true) { + continue; + } + let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb)); + if let Some((j, sb)) = found { + if let Some(u) = used_a.get_mut(i) { + *u = true; + } + if let Some(u) = used_b.get_mut(j) { + *u = true; + } + pairs.push((sa.id, sb.id)); + } + } + } + pairs +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + let v = a + (b - a) * t; + if v.is_finite() { + v + } else if t < 0.5 { + a + } else { + b + } +} + +/// Interpolate a box from `a` to `b` at `t` (0..1): position and size linearly, rotation along the +/// shortest way round; flips switch at the midpoint. +pub fn morph_xfrm(a: Xfrm, b: Xfrm, t: f64) -> Xfrm { + let t = clampf(t, 0.0, 1.0, 0.0); + if t >= 1.0 { + return b; + } + if t <= 0.0 { + return a; + } + let ra = if a.rot.is_finite() { a.rot } else { 0.0 }; + let rb = if b.rot.is_finite() { b.rot } else { 0.0 }; + let mut d = (rb - ra).rem_euclid(360.0); + if d > 180.0 { + d -= 360.0; + } + let rot = (ra + d * t).rem_euclid(360.0); + let late = t >= 0.5; + Xfrm { + x: lerp(a.x, b.x, t), + y: lerp(a.y, b.y, t), + w: lerp(a.w, b.w, t), + h: lerp(a.h, b.h, t), + rot: if rot.is_finite() { rot } else { 0.0 }, + flip_h: if late { b.flip_h } else { a.flip_h }, + flip_v: if late { b.flip_v } else { a.flip_v }, + } +} diff --git a/crates/anim/src/path.rs b/crates/anim/src/path.rs new file mode 100644 index 0000000..ffe4f5e --- /dev/null +++ b/crates/anim/src/path.rs @@ -0,0 +1,293 @@ +//! Motion paths: our SVG-like syntax (`M x y L x y C x1 y1 x2 y2 x y Q x1 y1 x y Z E`, lowercase +//! = relative) in slide-fraction units, relative to the shape's start position. Positions are +//! evaluated by arc length. + +use std::fmt::Write as _; + +const MAX_TOKENS: usize = 100_000; +const MAX_POINTS: usize = 20_000; +const CURVE_STEPS: usize = 16; + +/// A flattened motion path. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MotionPath { + /// Polyline vertices (slide fractions). + pub points: Vec<(f64, f64)>, + /// Cumulative arc length at each vertex. + cum: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Tok { + Cmd(char), + Num(f64), +} + +fn tokenize(s: &str) -> Vec { + let cs: Vec = s.chars().take(1_000_000).collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < cs.len() && out.len() < MAX_TOKENS { + let c = cs.get(i).copied().unwrap_or(' '); + if c.is_whitespace() || c == ',' { + i += 1; + continue; + } + if c.is_ascii_digit() || c == '.' || c == '-' || c == '+' { + let start = i; + i += 1; + let mut seen_dot = c == '.'; + while let Some(&d) = cs.get(i) { + if d.is_ascii_digit() { + i += 1; + } else if d == '.' && !seen_dot { + seen_dot = true; + i += 1; + } else if (d == 'e' || d == 'E') && { + let n1 = cs.get(i + 1).copied().unwrap_or(' '); + let n2 = cs.get(i + 2).copied().unwrap_or(' '); + n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit()) + } { + i += 2; + while cs.get(i).is_some_and(|d| d.is_ascii_digit()) { + i += 1; + } + break; + } else { + break; + } + } + let text: String = cs.get(start..i).map(|s| s.iter().collect()).unwrap_or_default(); + match text.parse::() { + Ok(v) if v.is_finite() => out.push(Tok::Num(v)), + _ => out.push(Tok::Cmd('?')), + } + continue; + } + out.push(Tok::Cmd(c)); + i += 1; + } + out +} + +impl MotionPath { + /// Parse a path. Malformed input yields the part parsed before the error (possibly empty). + pub fn parse(s: &str) -> MotionPath { + let toks = tokenize(s); + let mut pts: Vec<(f64, f64)> = Vec::new(); + let mut cur = (0.0, 0.0); + let mut start = (0.0, 0.0); + let mut cmd = 'M'; + let mut i = 0; + let push = |pts: &mut Vec<(f64, f64)>, p: (f64, f64)| { + if pts.len() < MAX_POINTS { + pts.push(p); + } + }; + let num = |i: usize| match toks.get(i) { + Some(Tok::Num(v)) => Some(*v), + _ => None, + }; + while i < toks.len() { + if let Some(Tok::Cmd(c)) = toks.get(i) { + cmd = *c; + i += 1; + match cmd { + 'Z' | 'z' => { + push(&mut pts, start); + cur = start; + continue; + } + 'E' | 'e' => break, + 'M' | 'm' | 'L' | 'l' | 'C' | 'c' | 'Q' | 'q' | 'H' | 'h' | 'V' | 'v' => continue, + _ => break, + } + } + let rel = cmd.is_ascii_lowercase(); + let base = if rel { cur } else { (0.0, 0.0) }; + let need = match cmd.to_ascii_uppercase() { + 'M' | 'L' => 2, + 'C' => 6, + 'Q' => 4, + 'H' | 'V' => 1, + _ => break, + }; + let mut v = [0.0f64; 6]; + let mut ok = true; + for (k, slot) in v.iter_mut().enumerate().take(need) { + match num(i + k) { + Some(x) => *slot = x, + None => ok = false, + } + } + if !ok { + break; + } + i += need; + match cmd.to_ascii_uppercase() { + 'M' => { + cur = (base.0 + v[0], base.1 + v[1]); + start = cur; + // A move inside a motion path is a jump; it stays continuous as a segment. + push(&mut pts, cur); + cmd = if rel { 'l' } else { 'L' }; + } + 'L' => { + if pts.is_empty() { + push(&mut pts, cur); + } + cur = (base.0 + v[0], base.1 + v[1]); + push(&mut pts, cur); + } + 'H' | 'V' => { + if pts.is_empty() { + push(&mut pts, cur); + } + cur = if cmd.eq_ignore_ascii_case(&'H') { (base.0 + v[0], cur.1) } else { (cur.0, base.1 + v[0]) }; + push(&mut pts, cur); + } + 'C' => { + if pts.is_empty() { + push(&mut pts, cur); + } + let p0 = cur; + let p1 = (base.0 + v[0], base.1 + v[1]); + let p2 = (base.0 + v[2], base.1 + v[3]); + let p3 = (base.0 + v[4], base.1 + v[5]); + for k in 1..=CURVE_STEPS { + let t = k as f64 / CURVE_STEPS as f64; + let u = 1.0 - t; + let a = u * u * u; + let b = 3.0 * u * u * t; + let c = 3.0 * u * t * t; + let d = t * t * t; + push(&mut pts, (a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0, a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1)); + } + cur = p3; + } + 'Q' => { + if pts.is_empty() { + push(&mut pts, cur); + } + let p0 = cur; + let p1 = (base.0 + v[0], base.1 + v[1]); + let p2 = (base.0 + v[2], base.1 + v[3]); + for k in 1..=CURVE_STEPS { + let t = k as f64 / CURVE_STEPS as f64; + let u = 1.0 - t; + push(&mut pts, (u * u * p0.0 + 2.0 * u * t * p1.0 + t * t * p2.0, u * u * p0.1 + 2.0 * u * t * p1.1 + t * t * p2.1)); + } + cur = p2; + } + _ => break, + } + } + pts.retain(|p| p.0.is_finite() && p.1.is_finite()); + let mut cum = Vec::with_capacity(pts.len()); + let mut acc = 0.0; + let mut prev: Option<(f64, f64)> = None; + for p in &pts { + if let Some(q) = prev { + let d = ((p.0 - q.0).powi(2) + (p.1 - q.1).powi(2)).sqrt(); + if d.is_finite() { + acc += d; + } + } + cum.push(acc); + prev = Some(*p); + } + MotionPath { points: pts, cum } + } + + /// Total arc length (slide fractions). + pub fn length(&self) -> f64 { + self.cum.last().copied().unwrap_or(0.0) + } + + /// Position at arc-length fraction `p` (0..1), relative to the path's origin (0, 0). + pub fn at(&self, p: f64) -> (f64, f64) { + let first = self.points.first().copied().unwrap_or((0.0, 0.0)); + let len = self.length(); + if len.is_nan() || len <= 0.0 || !len.is_finite() { + return first; + } + let p = crate::clampf(p, 0.0, 1.0, 0.0); + let target = p * len; + let i = self.cum.partition_point(|c| *c < target); + if i == 0 { + return first; + } + let (Some(a), Some(b), Some(ca), Some(cb)) = (self.points.get(i - 1), self.points.get(i), self.cum.get(i - 1), self.cum.get(i)) else { + return self.points.last().copied().unwrap_or(first); + }; + let seg = cb - ca; + let f = if seg > 0.0 { (target - ca) / seg } else { 0.0 }; + (a.0 + (b.0 - a.0) * f, a.1 + (b.1 - a.1) * f) + } +} + +fn polygon(n: usize, r: f64, rot0: f64) -> String { + // Regular polygon through (0, 0), centred below it. + let mut s = String::from("M 0 0"); + let c = (0.0, r); + let a0 = -std::f64::consts::FRAC_PI_2 + rot0; + let start = (c.0 + r * a0.cos(), c.1 + r * a0.sin()); + for k in 1..=n { + let a = a0 + std::f64::consts::TAU * k as f64 / n as f64; + let _ = write!(s, " L {:.4} {:.4}", c.0 + r * a.cos() - start.0, c.1 + r * a.sin() - start.1); + } + s.push_str(" E"); + s +} + +/// The path a preset motion-path effect uses when the animation has none of its own. +pub fn default_path(effect: &str, option: &str) -> String { + let (sx, sy, swap) = match option { + "up" => (1.0, -1.0, false), + "right" => (1.0, 1.0, true), + "left" => (-1.0, 1.0, true), + _ => (1.0, 1.0, false), + }; + // Base paths go down; `swap` turns them to the horizontal. + let tr = |pts: &[(f64, f64)]| -> Vec<(f64, f64)> { pts.iter().map(|&(x, y)| if swap { (y * sx, x * sy) } else { (x * sx, y * sy) }).collect() }; + let fmt = |head: &str, pts: Vec<(f64, f64)>| { + let mut s = String::from(head); + for (x, y) in pts { + let _ = write!(s, " {x:.4} {y:.4}"); + } + s.push_str(" E"); + s + }; + match effect { + "lines" => fmt("M 0 0 L", tr(&[(0.0, 0.25)])), + "arcs" => fmt("M 0 0 C", tr(&[(0.1, 0.05), (0.1, 0.2), (0.0, 0.25)])), + "turns" => { + let p = tr(&[(0.0, 0.12), (0.0, 0.2), (0.05, 0.25), (0.12, 0.25)]); + let mut s = String::from("M 0 0"); + if let Some((x, y)) = p.first() { + let _ = write!(s, " L {x:.4} {y:.4} C"); + } + for (x, y) in p.iter().skip(1) { + let _ = write!(s, " {x:.4} {y:.4}"); + } + s.push_str(" E"); + s + } + "shapes" => match option { + "diamond" => polygon(4, 0.1, 0.0), + "hexagon" => polygon(6, 0.1, 0.0), + "triangle" => polygon(3, 0.1, 0.0), + "square" => polygon(4, 0.1, std::f64::consts::FRAC_PI_4), + "octagon" => polygon(8, 0.1, 0.0), + "pentagon" => polygon(5, 0.1, 0.0), + "trapezoid" => "M 0 0 L 0.1 0 L 0.15 0.15 L -0.05 0.15 Z E".into(), + "parallelogram" => "M 0 0 L 0.15 0 L 0.1 0.15 L -0.05 0.15 Z E".into(), + _ => { + "M 0 0 C 0.0552 0 0.1 0.0448 0.1 0.1 C 0.1 0.1552 0.0552 0.2 0 0.2 C -0.0552 0.2 -0.1 0.1552 -0.1 0.1 C -0.1 0.0448 -0.0552 0 0 0 Z E" + .into() + } + }, + "loops" => "M 0 0 C 0.08 -0.1 0.16 0 0.08 0.05 C 0 0.1 -0.08 0.1 -0.08 0 C -0.08 -0.1 0 -0.05 0 0 E".into(), + _ => String::new(), + } +} diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs new file mode 100644 index 0000000..efd236d --- /dev/null +++ b/crates/anim/src/show.rs @@ -0,0 +1,232 @@ +//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop +//! and automatic advance. + +use slidecraft_model::Presentation; + +use crate::Timeline; + +/// What the UI should do after a navigation call. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShowAction { + /// Nothing changed (e.g. back on the first slide). + None, + /// Start playing main step `n` of the current slide (clock from 0). + PlayStep(usize), + /// Step `n` jumped to its end (it was playing); nothing plays now. + FinishStep(usize), + /// Went back before step `n` (showing the state before it). + StepBack(usize), + /// Moved to slide index `n` (play its transition; `ShowState::playing` tells whether step 0 + /// starts by itself). + Slide(usize), + /// Past the last slide: show the end screen. + End, + /// Leave the show (next on the end screen). + Exit, +} + +/// Where a slide show is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct ShowState { + /// Index into `presentation.slides`. + pub slide: usize, + /// Main step that plays next or is playing; `== steps` when the slide is fully built. + pub step: usize, + /// Step `step` is playing (its clock runs). With `step`, gives `Timeline::state(.., step, t)`. + pub playing: bool, + /// On the end screen. + pub ended: bool, +} + +/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus +/// hidden slides. +pub fn show_order(pres: &Presentation) -> Vec { + let n = pres.slides.len(); + let base: Vec = match pres.show.custom_show.as_ref().and_then(|name| pres.custom_shows.iter().find(|c| &c.name == name)) { + Some(cs) => cs.slides.iter().filter_map(|id| pres.slides.iter().position(|s| s.id == *id)).collect(), + None => match pres.show.range { + Some((a, b)) => { + let a = (a.max(1) as usize).saturating_sub(1); + let b = (b as usize).min(n); + (a..b).collect() + } + None => (0..n).collect(), + }, + }; + base.into_iter().filter(|i| pres.slides.get(*i).is_some_and(|s| !s.hidden)).collect() +} + +fn looping(pres: &Presentation) -> bool { + pres.show.loop_until_esc || pres.show.show_type == "kiosk" +} + +impl ShowState { + /// Start at the first slide of the show order. + pub fn new(pres: &Presentation) -> ShowState { + match show_order(pres).first() { + Some(&i) => ShowState::start_at(pres, i), + None => ShowState { ended: true, ..Default::default() }, + } + } + + /// Start at slide `i` (hidden slides allowed: "from current slide"). + pub fn start_at(pres: &Presentation, i: usize) -> ShowState { + let mut s = ShowState::default(); + if i < pres.slides.len() { + s.enter(pres, i); + } else { + s.ended = true; + } + s + } + + /// The timeline of slide `i`. + pub fn timeline(pres: &Presentation, i: usize) -> Timeline { + match pres.slides.get(i) { + Some(s) => Timeline::new(s, pres.slide_size.width, pres.slide_size.height), + None => Timeline::default(), + } + } + + /// Main steps of slide `i` (0 with Show without animation). + pub fn steps(pres: &Presentation, i: usize) -> usize { + if pres.show.without_animation { 0 } else { ShowState::timeline(pres, i).steps() } + } + + fn enter(&mut self, pres: &Presentation, i: usize) { + self.slide = i; + self.step = 0; + self.ended = false; + self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0); + } + + fn next_index(&self, pres: &Presentation) -> Option { + let order = show_order(pres); + match order.iter().position(|&i| i == self.slide) { + Some(p) => order.get(p + 1).copied(), + None => order.iter().copied().find(|&i| i > self.slide), + } + } + + fn prev_index(&self, pres: &Presentation) -> Option { + let order = show_order(pres); + match order.iter().position(|&i| i == self.slide) { + Some(p) => p.checked_sub(1).and_then(|q| order.get(q).copied()), + None => order.iter().copied().rev().find(|&i| i < self.slide), + } + } + + /// Click / → / Space: finish the playing step, else play the next step, else go to the next slide. + pub fn next(&mut self, pres: &Presentation) -> ShowAction { + if pres.slides.is_empty() { + self.ended = true; + return ShowAction::Exit; + } + if self.ended { + return ShowAction::Exit; + } + if self.playing { + return self.step_done(pres); + } + let steps = ShowState::steps(pres, self.slide); + if self.step < steps { + self.playing = true; + return ShowAction::PlayStep(self.step); + } + match self.next_index(pres) { + Some(i) => { + self.enter(pres, i); + ShowAction::Slide(i) + } + None if looping(pres) => match show_order(pres).first() { + Some(&i) => { + self.enter(pres, i); + ShowAction::Slide(i) + } + None => { + self.ended = true; + ShowAction::End + } + }, + None => { + self.ended = true; + self.playing = false; + ShowAction::End + } + } + } + + /// The playing step reached its end (the UI's clock passed `step_duration`). + pub fn step_done(&mut self, pres: &Presentation) -> ShowAction { + if !self.playing { + return ShowAction::None; + } + let n = self.step; + self.playing = false; + self.step = (self.step + 1).min(ShowState::steps(pres, self.slide)); + ShowAction::FinishStep(n) + } + + /// ← / Backspace: undo the last step, else go to the previous slide fully built. + pub fn prev(&mut self, pres: &Presentation) -> ShowAction { + if self.ended { + self.ended = false; + if self.slide >= pres.slides.len() { + return ShowAction::None; + } + self.step = ShowState::steps(pres, self.slide); + self.playing = false; + return ShowAction::Slide(self.slide); + } + if self.playing { + self.playing = false; + return ShowAction::StepBack(self.step); + } + if self.step > 0 { + self.step -= 1; + return ShowAction::StepBack(self.step); + } + let target = match self.prev_index(pres) { + Some(i) => Some(i), + None if looping(pres) => show_order(pres).last().copied().filter(|&i| i != self.slide), + None => None, + }; + match target { + Some(i) => { + self.slide = i; + self.step = ShowState::steps(pres, i); + self.playing = false; + ShowAction::Slide(i) + } + None => ShowAction::None, + } + } + + /// Jump to slide `i` (number + Enter, See All Slides); hidden slides are shown when jumped to. + pub fn goto(&mut self, pres: &Presentation, i: usize) -> ShowAction { + if i >= pres.slides.len() { + return ShowAction::None; + } + self.enter(pres, i); + ShowAction::Slide(i) + } + + /// Seconds after which the current slide advances by itself (Advance Slide ▸ After), when the + /// show uses timings. + pub fn advance_after(&self, pres: &Presentation) -> Option { + if !pres.show.use_timings || self.ended { + return None; + } + pres.slides.get(self.slide).and_then(|s| s.transition.as_ref()).and_then(|t| t.advance_after_ms).map(|ms| ms as f64 / 1000.0) + } + + /// Should the show advance now, `elapsed` seconds after the slide appeared? Waits for the + /// slide's animations: auto-advance happens once nothing plays and the time is up; remaining + /// click steps play automatically with timings. + pub fn auto_advance_due(&self, pres: &Presentation, elapsed: f64) -> bool { + match self.advance_after(pres) { + Some(after) => !self.playing && elapsed.is_finite() && elapsed >= after, + None => false, + } + } +} diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs new file mode 100644 index 0000000..837e434 --- /dev/null +++ b/crates/anim/src/tests.rs @@ -0,0 +1,634 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use slidecraft_model::anim::{ANIMATIONS, TRANSITIONS, TextBuild}; +use slidecraft_model::{ + AnimClass, AnimStart, Animation, ColorRef, CustomShow, Presentation, Rgba, Shape, ShapeId, Slide, SlideId, TextBody, Transition, Xfrm, +}; + +use crate::*; + +const W: f64 = 960.0; +const H: f64 = 540.0; + +fn shape(id: u32, name: &str) -> Shape { + Shape { id: ShapeId(id), name: name.into(), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() } +} + +fn anim(shape: u32, class: AnimClass, effect: &str, start: AnimStart) -> Animation { + Animation { shape: ShapeId(shape), class, effect: effect.into(), start, duration_ms: 1000, ..Default::default() } +} + +fn slide_with(anims: Vec) -> Slide { + Slide { shapes: (1..=4).map(|i| shape(i, &format!("S{i}"))).collect(), animations: anims, ..Default::default() } +} + +fn finite(s: &AnimState) -> bool { + s.opacity.is_finite() + && s.offset_x.is_finite() + && s.offset_y.is_finite() + && s.scale_x.is_finite() + && s.scale_y.is_finite() + && s.rotate.is_finite() + && s.clip.is_none_or(|c| c.iter().all(|v| v.is_finite())) +} + +#[test] +fn default_state_is_identity() { + let s = AnimState::default(); + assert!(s.visible && s.opacity == 1.0 && s.scale_x == 1.0 && s.scale_y == 1.0); + assert!(s.is_identity()); +} + +#[test] +fn on_click_starts_steps() { + let tl = Timeline::new( + &slide_with(vec![ + anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick), + anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick), + anim(3, AnimClass::Entrance, "fade", AnimStart::OnClick), + ]), + W, + H, + ); + assert_eq!(tl.steps(), 3); + assert!(!tl.step_is_auto(0)); + assert!((tl.step_duration(1) - 1.0).abs() < 1e-9); +} + +#[test] +fn with_previous_joins_step() { + let tl = Timeline::new( + &slide_with(vec![anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick), anim(2, AnimClass::Entrance, "fade", AnimStart::WithPrevious)]), + W, + H, + ); + assert_eq!(tl.steps(), 1); + let e = tl.effects(); + assert_eq!(e[1].step, 0); + assert_eq!(e[1].start, 0.0); + assert!((tl.step_duration(0) - 1.0).abs() < 1e-9); +} + +#[test] +fn after_previous_chains() { + let tl = Timeline::new( + &slide_with(vec![ + anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick), + anim(2, AnimClass::Entrance, "fade", AnimStart::AfterPrevious), + anim(3, AnimClass::Entrance, "fade", AnimStart::AfterPrevious), + ]), + W, + H, + ); + assert_eq!(tl.steps(), 1); + let e = tl.effects(); + assert!((e[1].start - 1.0).abs() < 1e-9); + assert!((e[2].start - 2.0).abs() < 1e-9); + assert!((tl.step_duration(0) - 3.0).abs() < 1e-9); +} + +#[test] +fn delays_shift_start() { + let mut a = anim(2, AnimClass::Entrance, "fade", AnimStart::WithPrevious); + a.delay_ms = 250; + let mut b = anim(3, AnimClass::Entrance, "fade", AnimStart::AfterPrevious); + b.delay_ms = 500; + let tl = Timeline::new(&slide_with(vec![anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick), a, b]), W, H); + let e = tl.effects(); + assert!((e[1].start - 0.25).abs() < 1e-9); + // After previous: after the latest end (1.25) plus its delay. + assert!((e[2].start - 1.75).abs() < 1e-9); + assert!((tl.step_duration(0) - 2.75).abs() < 1e-9); +} + +#[test] +fn auto_first_step() { + let tl = Timeline::new( + &slide_with(vec![anim(1, AnimClass::Entrance, "fade", AnimStart::AfterPrevious), anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)]), + W, + H, + ); + assert_eq!(tl.steps(), 2); + assert!(tl.step_is_auto(0)); + assert!(!tl.step_is_auto(1)); +} + +#[test] +fn hidden_before_entrance() { + let tl = Timeline::new(&slide_with(vec![anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick)]), W, H); + assert!(!tl.state(ShapeId(1), None, 0, 0.0).visible); + let mid = tl.state(ShapeId(1), None, 0, 0.5); + assert!(mid.visible && mid.opacity > 0.0 && mid.opacity < 1.0); + let done = tl.state(ShapeId(1), None, 1, 0.0); + assert!(done.visible && (done.opacity - 1.0).abs() < 1e-9); + // Unanimated shapes are untouched. + assert!(tl.state(ShapeId(2), None, 0, 0.0).is_identity()); +} + +#[test] +fn hidden_after_exit() { + let tl = Timeline::new(&slide_with(vec![anim(1, AnimClass::Exit, "fadeOut", AnimStart::OnClick)]), W, H); + assert!(tl.state(ShapeId(1), None, 0, 0.0).visible); + let mid = tl.state(ShapeId(1), None, 0, 0.5); + assert!(mid.visible && mid.opacity < 1.0); + assert!(!tl.state(ShapeId(1), None, 1, 0.0).visible); + assert!(!tl.state(ShapeId(1), None, 0, 5.0).visible); +} + +#[test] +fn entrance_then_exit() { + let tl = Timeline::new( + &slide_with(vec![anim(1, AnimClass::Entrance, "appear", AnimStart::OnClick), anim(1, AnimClass::Exit, "disappear", AnimStart::OnClick)]), + W, + H, + ); + assert!(!tl.state(ShapeId(1), None, 0, 0.0).visible); + assert!(tl.state(ShapeId(1), None, 1, 0.0).visible); + assert!(!tl.state(ShapeId(1), None, 2, 0.0).visible); +} + +#[test] +fn emphasis_returns_or_persists() { + let mut spin = anim(1, AnimClass::Emphasis, "spin", AnimStart::OnClick); + spin.amount = Some(90.0); + let tl = Timeline::new(&slide_with(vec![anim(2, AnimClass::Emphasis, "pulse", AnimStart::OnClick), spin]), W, H); + let p = tl.state(ShapeId(2), None, 0, 0.5); + assert!(p.scale_x > 1.0); + assert!(tl.state(ShapeId(2), None, 1, 0.0).is_identity()); + assert!((tl.state(ShapeId(1), None, 2, 0.0).rotate - 90.0).abs() < 1e-9); +} + +#[test] +fn color_emphasis_tints() { + let mut a = anim(1, AnimClass::Emphasis, "fillColor", AnimStart::OnClick); + a.color = Some(ColorRef::rgb(Rgba::rgb(255, 0, 0))); + let tl = Timeline::new(&slide_with(vec![a]), W, H); + assert_eq!(tl.state(ShapeId(1), None, 1, 0.0).tint, Some(Rgba::rgb(255, 0, 0))); +} + +#[test] +fn every_animation_evaluates_finite() { + for (id, _, class, _, _, opts) in ANIMATIONS { + let mut options: Vec<&str> = opts.to_vec(); + options.push(""); + for o in options { + let mut a = anim(1, *class, id, AnimStart::OnClick); + a.option = o.into(); + let tl = Timeline::new(&slide_with(vec![a]), W, H); + for (step, t) in [(0, 0.0), (0, 0.001), (0, 0.25), (0, 0.5), (0, 0.999), (0, 1.0), (0, 2.0), (1, 0.0)] { + let s = tl.state(ShapeId(1), None, step, t); + assert!(finite(&s), "{id} {o} {step} {t}: {s:?}"); + } + let end = tl.state(ShapeId(1), None, 1, 0.0); + match class { + AnimClass::Entrance => assert!(end.visible && (end.opacity - 1.0).abs() < 1e-9, "{id}"), + AnimClass::Exit => assert!(!end.visible, "{id}"), + _ => {} + } + } + } +} + +#[test] +fn fly_starts_off_slide() { + let mut a = anim(1, AnimClass::Entrance, "fly", AnimStart::OnClick); + a.option = "l".into(); + let tl = Timeline::new(&slide_with(vec![a]), W, H); + let s = tl.state(ShapeId(1), None, 0, 1e-6); + // Shape at x=100, w=200: must start at or beyond the left edge. + assert!(100.0 + s.offset_x + 200.0 <= 0.01, "{s:?}"); + let s = tl.state(ShapeId(1), None, 0, 0.999_999); + assert!(s.offset_x.abs() < 1.0); +} + +#[test] +fn wipe_clips() { + let mut a = anim(1, AnimClass::Entrance, "wipe", AnimStart::OnClick); + a.option = "l".into(); + let tl = Timeline::new(&slide_with(vec![a]), W, H); + let c = tl.state(ShapeId(1), None, 0, 0.5).clip.unwrap(); + assert!(c[0] == 0.0 && c[2] > 0.0 && c[2] < 1.0); +} + +#[test] +fn repeat_and_auto_reverse_extend_duration() { + let mut a = anim(1, AnimClass::Emphasis, "pulse", AnimStart::OnClick); + a.repeat = 3; + a.auto_reverse = true; + let tl = Timeline::new(&slide_with(vec![a]), W, H); + assert!((tl.step_duration(0) - 6.0).abs() < 1e-9); + // Until next click: one iteration counts, keeps playing within the step. + let mut b = anim(2, AnimClass::Emphasis, "spin", AnimStart::OnClick); + b.repeat = u32::MAX; + let tl = Timeline::new(&slide_with(vec![b]), W, H); + assert!((tl.step_duration(0) - 1.0).abs() < 1e-9); + let s = tl.state(ShapeId(2), None, 0, 10.5); + assert!(s.rotate > 0.0 && s.rotate < 360.0); +} + +#[test] +fn rewind_reverts_entrance() { + let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick); + a.rewind = true; + let tl = Timeline::new(&slide_with(vec![a]), W, H); + assert!(!tl.state(ShapeId(1), None, 1, 0.0).visible); +} + +#[test] +fn after_hide_on_next_click() { + let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick); + a.after = Some("hideOnNextClick".into()); + let tl = Timeline::new(&slide_with(vec![a, anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)]), W, H); + assert!(tl.state(ShapeId(1), None, 0, 5.0).visible); + assert!(!tl.state(ShapeId(1), None, 1, 0.0).visible); +} + +#[test] +fn triggered_sequences() { + let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick); + a.trigger = Some(ShapeId(3)); + let tl = Timeline::new(&slide_with(vec![anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick), a]), W, H); + assert_eq!(tl.steps(), 1); + assert_eq!(tl.trigger_shapes(), vec![ShapeId(3)]); + assert_eq!(tl.trigger_steps(ShapeId(3)), 1); + // Not played: hidden even after the main sequence. + assert!(!tl.state(ShapeId(1), None, 1, 0.0).visible); + let tp = [TriggerPlay { trigger: ShapeId(3), step: 1, t: 0.0 }]; + assert!(tl.state_with(ShapeId(1), None, 0, 0.0, &tp).visible); +} + +#[test] +fn text_build_by_paragraph() { + let mut s = slide_with(vec![]); + s.shapes[0].text = Some(TextBody::from_text("one\ntwo\nthree")); + let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick); + a.text_build = TextBuild::ByParagraph; + s.animations = vec![a.clone()]; + let tl = Timeline::new(&s, W, H); + let n = tl.paragraph_targets(ShapeId(1)).len(); + assert_eq!(n, 3); + // On click: one click per paragraph. + assert_eq!(tl.steps(), n); + if n >= 2 { + assert!(tl.state(ShapeId(1), Some(0), 1, 0.0).visible); + assert!(!tl.state(ShapeId(1), Some(1), 1, 0.0).visible); + } + // With previous: staggered within one step. + a.start = AnimStart::WithPrevious; + s.animations = vec![a]; + let tl = Timeline::new(&s, W, H); + assert_eq!(tl.steps(), 1); + let e = tl.effects(); + if e.len() >= 2 { + assert!((e[1].start - e[0].start - 0.5).abs() < 1e-9); + } + // The shape itself isn't hidden. + assert!(tl.state(ShapeId(1), None, 0, 0.0).visible); +} + +#[test] +fn hostile_times_and_durations() { + let mut a = anim(1, AnimClass::Entrance, "fly", AnimStart::OnClick); + a.duration_ms = u32::MAX; + a.delay_ms = u32::MAX; + a.repeat = u32::MAX - 1; + a.smooth_start = f64::NAN; + a.smooth_end = -5.0; + a.bounce_end = f64::INFINITY; + a.amount = Some(f64::NAN); + let mut b = anim(2, AnimClass::Emphasis, "growShrink", AnimStart::AfterPrevious); + b.amount = Some(1e300); + b.duration_ms = 0; + let tl = Timeline::new(&slide_with(vec![a, b, anim(99, AnimClass::Path, "customPath", AnimStart::OnClick)]), f64::NAN, -1.0); + for t in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0, 1e12, 1e300] { + for step in [0, 1, 2, 3, usize::MAX] { + for id in [1, 2, 99] { + let s = tl.state(ShapeId(id), None, step, t); + assert!(finite(&s)); + let s = tl.state(ShapeId(id), Some(usize::MAX), step, t); + assert!(finite(&s)); + } + } + let _ = tl.locate(t); + let _ = tl.state_at(ShapeId(1), None, t); + } + assert!(tl.step_duration(usize::MAX) == 0.0); +} + +#[test] +fn empty_slide() { + let tl = Timeline::new(&Slide::default(), W, H); + assert_eq!(tl.steps(), 0); + assert!(tl.is_empty()); + assert!(tl.state(ShapeId(1), None, 0, 1.0).is_identity()); + assert_eq!(tl.locate(5.0), (0, 0.0)); +} + +#[test] +fn locate_play_all_clock() { + let tl = Timeline::new( + &slide_with(vec![anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick), anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)]), + W, + H, + ); + assert_eq!(tl.locate(0.5), (0, 0.5)); + assert_eq!(tl.locate(1.5), (1, 0.5)); + assert_eq!(tl.locate(9.0), (2, 0.0)); + assert!((tl.total_duration() - 2.0).abs() < 1e-9); +} + +#[test] +fn motion_path_parse_and_eval() { + let p = MotionPath::parse("M 0 0 L 0.25 0 L 0.25 0.25 E"); + assert!((p.length() - 0.5).abs() < 1e-9); + let (x, y) = p.at(0.5); + assert!((x - 0.25).abs() < 1e-9 && y.abs() < 1e-9); + let (x, y) = p.at(1.0); + assert!((x - 0.25).abs() < 1e-9 && (y - 0.25).abs() < 1e-9); + // Relative commands and curves. + let q = MotionPath::parse("m 0 0 l 0.1 0 c 0 0.1 0 0.1 0 0.2 z"); + assert!(q.length() > 0.0); + let c = MotionPath::parse("M0,0C0.1,0,0.2,0.1,0.2,0.2E"); + assert!(c.points.len() > 2); + let e = MotionPath::parse("M 0 0 L 1e-1 -2.5E-1 E"); + let (x, y) = e.at(1.0); + assert!((x - 0.1).abs() < 1e-9 && (y + 0.25).abs() < 1e-9); +} + +#[test] +fn malformed_paths_dont_panic() { + for s in [ + "", + "E", + "M", + "M 0", + "L 1 1", + "C 1 2 3", + "M 0 0 L x y", + "M NaN 1 L inf 2", + "M 1e400 0 L 0 0", + "Z Z Z", + "M 0 0 Q", + "--..ee", + "M 0 0 H 1 V 1 h -1 v -1", + "😀 M 1 1", + "M 0 0 L 0.5 0.5 X 9 9", + ] { + let p = MotionPath::parse(s); + for t in [f64::NAN, -1.0, 0.0, 0.5, 1.0, 2.0] { + let (x, y) = p.at(t); + assert!(x.is_finite() && y.is_finite(), "{s}"); + } + } + let big = "L 1 1 ".repeat(50_000); + let p = MotionPath::parse(&big); + assert!(p.points.len() <= 20_000); +} + +#[test] +fn preset_paths_move() { + for (id, _, class, _, _, opts) in ANIMATIONS.iter().filter(|a| a.2 == AnimClass::Path && a.0 != "customPath") { + let _ = class; + for o in opts.iter().copied().chain([""]) { + let p = MotionPath::parse(&default_path(id, o)); + assert!(p.length() > 0.0, "{id} {o}"); + } + } + let mut a = anim(1, AnimClass::Path, "lines", AnimStart::OnClick); + a.option = "right".into(); + let tl = Timeline::new(&slide_with(vec![a]), W, H); + let s = tl.state(ShapeId(1), None, 1, 0.0); + assert!((s.offset_x - 0.25 * W).abs() < 1e-6 && s.offset_y.abs() < 1e-6); +} + +#[test] +fn custom_path_offsets_in_points() { + let mut a = anim(1, AnimClass::Path, "customPath", AnimStart::OnClick); + a.path = Some("M 0 0 L 0.5 0.5 E".into()); + let tl = Timeline::new(&slide_with(vec![a]), W, H); + let s = tl.state(ShapeId(1), None, 1, 0.0); + assert!((s.offset_x - 480.0).abs() < 1e-6 && (s.offset_y - 270.0).abs() < 1e-6); +} + +#[test] +fn easing_endpoints() { + for (a, d, b) in [(0.0, 0.0, 0.0), (0.5, 0.5, 0.0), (0.3, 0.0, 0.0), (0.0, 0.7, 0.0), (0.9, 0.9, 0.0), (0.0, 0.0, 0.3)] { + assert!(ease(0.0, a, d, b).abs() < 1e-9, "{a} {d} {b}"); + assert!((ease(1.0, a, d, b) - 1.0).abs() < 1e-9, "{a} {d} {b}"); + assert!(ease(0.5, a, d, b).is_finite()); + } + assert!(ease(f64::NAN, f64::NAN, f64::NAN, f64::NAN).is_finite()); +} + +fn covers_at_end(layers: &[Layer]) -> bool { + layers.iter().any(|l| { + l.source == Source::New + && (l.alpha - 1.0).abs() < 1e-9 + && l.quad == [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] + && l.uv == [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] + }) +} + +#[test] +fn every_transition_finite_and_complete() { + for (id, _, _, _, opts) in TRANSITIONS { + let mut options: Vec<&str> = opts.to_vec(); + options.push(""); + for o in options { + for t in [0.0, 0.01, 0.25, 0.5, 0.75, 0.99, 1.0] { + let ls = transition_layers(id, o, t); + assert!(!ls.is_empty(), "{id} {o} {t}"); + assert!(ls.len() < 5000, "{id} {o} {t}"); + for l in &ls { + assert!(l.alpha.is_finite() && (0.0..=1.0).contains(&l.alpha)); + assert!(l.quad.iter().chain(l.uv.iter()).all(|p| p[0].is_finite() && p[1].is_finite())); + assert!(l.src.iter().all(|v| v.is_finite())); + } + } + assert!(covers_at_end(&transition_layers(id, o, 1.0)), "{id} {o}"); + let start = transition_layers(id, o, 0.0); + assert!(start.iter().any(|l| l.source == Source::Old && l.alpha == 1.0), "{id} {o}"); + } + } +} + +#[test] +fn transitions_hostile_t() { + for (id, ..) in TRANSITIONS { + for t in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -5.0, 1e12] { + let ls = transition_layers(id, "bogus-option", t); + assert!(!ls.is_empty()); + } + } + assert!(!transition_layers("no-such-kind", "", 0.5).is_empty()); +} + +#[test] +fn fade_and_push_shapes() { + let ls = transition_layers("fade", "smoothly", 0.5); + assert_eq!(ls.len(), 2); + assert_eq!(ls[1].source, Source::New); + assert!((ls[1].alpha - 0.5).abs() < 1e-9); + let ls = transition_layers("push", "l", 0.5); + let new = ls.iter().find(|l| l.source == Source::New).unwrap(); + // Pushing left: the new slide comes from the right. + assert!(new.quad[0][0] > 0.0); + let ls = transition_layers("fade", "throughBlack", 0.25); + assert!(ls.iter().all(|l| l.source != Source::New)); +} + +#[test] +fn mid_transitions_mix_sources() { + // Most transitions show something of both slides (or black/white) midway. + for (id, ..) in TRANSITIONS.iter().filter(|t| !matches!(t.0, "none" | "cut" | "flip")) { + let ls = transition_layers(id, "", 0.5); + assert!(ls.iter().any(|l| l.source != Source::New) || ls.len() > 1, "{id}"); + } +} + +#[test] +fn random_is_deterministic() { + assert_eq!(transition_layers("random", "x", 0.4), transition_layers("random", "x", 0.4)); + assert_eq!(transition_layers("dissolve", "", 0.4), transition_layers("dissolve", "", 0.4)); +} + +#[test] +fn morph_pairs_by_name_text_and_geometry() { + let mut a = Slide::default(); + let mut b = Slide::default(); + a.shapes = vec![shape(1, "!!Logo"), shape(2, "Title 1"), shape(3, "x"), shape(4, "Rect A")]; + b.shapes = vec![shape(14, "Rect B"), shape(13, "y"), shape(12, "Title 1"), shape(11, "!!Logo")]; + a.shapes[2].text = Some(TextBody::from_text("Hello")); + b.shapes[1].text = Some(TextBody::from_text("Hello")); + let p = morph_pairs(&a, &b); + assert!(p.contains(&(ShapeId(1), ShapeId(11)))); + assert!(p.contains(&(ShapeId(2), ShapeId(12)))); + assert!(p.contains(&(ShapeId(3), ShapeId(13)))); + assert!(p.contains(&(ShapeId(4), ShapeId(14)))); + assert_eq!(p.len(), 4); + assert!(morph_pairs(&Slide::default(), &b).is_empty()); +} + +#[test] +fn morph_xfrm_interpolates() { + let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) }; + let b = Xfrm { rot: 10.0, ..Xfrm::new(100.0, 50.0, 200.0, 100.0) }; + let m = morph_xfrm(a, b, 0.5); + assert!((m.x - 50.0).abs() < 1e-9 && (m.w - 150.0).abs() < 1e-9); + assert!(m.rot.abs() < 1e-9 || (m.rot - 360.0).abs() < 1e-9, "{}", m.rot); + assert_eq!(morph_xfrm(a, b, 1.0), b); + assert_eq!(morph_xfrm(a, b, f64::NAN), a); + let n = Xfrm { x: f64::NAN, rot: f64::INFINITY, ..Xfrm::default() }; + let m = morph_xfrm(n, b, 0.3); + assert!(m.rot.is_finite()); +} + +fn pres(n: usize) -> Presentation { + Presentation { slides: (0..n).map(|i| Arc::new(Slide { id: SlideId(1000 + i as u32), ..Default::default() })).collect(), ..Default::default() } +} + +#[test] +fn show_skips_hidden_slides() { + let mut p = pres(4); + Arc::make_mut(&mut p.slides[1]).hidden = true; + let mut s = ShowState::new(&p); + assert_eq!(s.slide, 0); + assert_eq!(s.next(&p), ShowAction::Slide(2)); + assert_eq!(s.prev(&p), ShowAction::Slide(0)); + // Jumping to a hidden slide shows it; next continues after it. + assert_eq!(s.goto(&p, 1), ShowAction::Slide(1)); + assert_eq!(s.next(&p), ShowAction::Slide(2)); + assert_eq!(s.next(&p), ShowAction::Slide(3)); + assert_eq!(s.next(&p), ShowAction::End); + assert_eq!(s.next(&p), ShowAction::Exit); + assert_eq!(s.goto(&p, 99), ShowAction::None); +} + +#[test] +fn show_loops() { + let mut p = pres(3); + p.show.loop_until_esc = true; + let mut s = ShowState::new(&p); + s.next(&p); + s.next(&p); + assert_eq!(s.next(&p), ShowAction::Slide(0)); + assert_eq!(s.prev(&p), ShowAction::Slide(2)); +} + +#[test] +fn show_custom_show_and_range() { + let mut p = pres(5); + p.custom_shows = vec![CustomShow { name: "Short".into(), slides: vec![SlideId(1003), SlideId(1001)] }]; + p.show.custom_show = Some("Short".into()); + assert_eq!(show_order(&p), vec![3, 1]); + let mut s = ShowState::new(&p); + assert_eq!(s.slide, 3); + assert_eq!(s.next(&p), ShowAction::Slide(1)); + p.show.custom_show = None; + p.show.range = Some((2, 4)); + assert_eq!(show_order(&p), vec![1, 2, 3]); + p.show.range = Some((0, 99)); + assert_eq!(show_order(&p), vec![0, 1, 2, 3, 4]); +} + +#[test] +fn show_steps_through_builds() { + let mut p = pres(2); + Arc::make_mut(&mut p.slides[0]).shapes = vec![shape(1, "A")]; + Arc::make_mut(&mut p.slides[0]).animations = vec![anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick)]; + let mut s = ShowState::new(&p); + assert!(!s.playing); + assert_eq!(s.next(&p), ShowAction::PlayStep(0)); + assert!(s.playing); + assert_eq!(s.step_done(&p), ShowAction::FinishStep(0)); + assert_eq!(s.step, 1); + assert_eq!(s.prev(&p), ShowAction::StepBack(0)); + assert_eq!(s.next(&p), ShowAction::PlayStep(0)); + assert_eq!(s.next(&p), ShowAction::FinishStep(0)); + assert_eq!(s.next(&p), ShowAction::Slide(1)); + // Back to a fully built previous slide. + assert_eq!(s.prev(&p), ShowAction::Slide(0)); + assert_eq!(s.step, 1); +} + +#[test] +fn show_auto_advance() { + let mut p = pres(2); + Arc::make_mut(&mut p.slides[0]).transition = Some(Transition { advance_after_ms: Some(2000), ..Default::default() }); + let s = ShowState::new(&p); + assert_eq!(s.advance_after(&p), Some(2.0)); + assert!(!s.auto_advance_due(&p, 1.0)); + assert!(s.auto_advance_due(&p, 2.5)); + assert!(!s.auto_advance_due(&p, f64::NAN)); + p.show.use_timings = false; + assert!(!s.auto_advance_due(&p, 5.0)); +} + +#[test] +fn show_empty_and_all_hidden() { + let p = pres(0); + let mut s = ShowState::new(&p); + assert!(s.ended); + assert_eq!(s.next(&p), ShowAction::Exit); + let _ = s.prev(&p); + let mut p = pres(2); + for sl in &mut p.slides { + Arc::make_mut(sl).hidden = true; + } + let mut s = ShowState::new(&p); + assert!(s.ended); + let _ = s.next(&p); + let _ = s.prev(&p); +} + +#[test] +fn compose_and_sanitize() { + let a = AnimState { opacity: 0.5, clip: Some([0.0, 0.0, 0.5, 1.0]), ..Default::default() }; + let b = AnimState { opacity: 0.5, offset_x: 3.0, clip: Some([0.25, 0.0, 1.0, 1.0]), ..Default::default() }; + let c = a.compose(&b); + assert!((c.opacity - 0.25).abs() < 1e-9 && c.offset_x == 3.0 && c.clip == Some([0.25, 0.0, 0.5, 1.0])); + let bad = AnimState { opacity: f64::NAN, rotate: f64::INFINITY, clip: Some([f64::NAN, 2.0, -1.0, 0.5]), ..Default::default() }.sanitized(); + assert!(finite(&bad)); +} diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs new file mode 100644 index 0000000..1c8e102 --- /dev/null +++ b/crates/anim/src/timeline.rs @@ -0,0 +1,476 @@ +//! A slide's animation sequence: click steps, effect start times and per-shape state over time. + +use slidecraft_model::anim::TextBuild; +use slidecraft_model::style::Fill; +use slidecraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm}; + +use crate::AnimState; +use crate::effects::{EffectSpec, exit_base, motion_path}; + +/// Delay between paragraphs of a by-paragraph build that isn't on click (seconds). +pub const PARAGRAPH_STAGGER: f64 = 0.5; +const MAX_EFFECTS: usize = 100_000; +const MAX_PARAGRAPHS: usize = 1_000; +const MAX_ITERATIONS: f64 = 1.0e6; + +/// Which sequence an effect belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Sequence { + /// The slide's main click sequence. + Main, + /// An interactive sequence started by clicking this shape. + Trigger(ShapeId), +} + +/// Timing of one (sub-)effect, for the Animation Pane timeline and the UI. +#[derive(Clone, Debug, PartialEq)] +pub struct EffectTiming { + /// Index into `slide.animations`. + pub anim: usize, + /// Animated shape and paragraph (`None` = the whole shape). + pub shape: ShapeId, + pub paragraph: Option, + pub sequence: Sequence, + /// Click step within its sequence. + pub step: usize, + /// Start within the step (seconds, delay included). + pub start: f64, + /// One iteration (seconds). + pub duration: f64, + /// Repeat count (fractional allowed); 1 = once. + pub iterations: f64, + /// Repeats until the next click. + pub until_next_click: bool, + pub auto_reverse: bool, + pub rewind: bool, + /// Time from start to end (all iterations, reverse included; one iteration if until-click). + pub span: f64, + pub class: AnimClass, +} + +/// Playhead of a triggered sequence: `step` clicks on the trigger fully played, the current one +/// playing for `t` seconds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TriggerPlay { + pub trigger: ShapeId, + pub step: usize, + pub t: f64, +} + +#[derive(Clone, Debug, Default)] +struct StepInfo { + auto: bool, + duration: f64, +} + +#[derive(Clone, Debug)] +enum After { + None, + Hide, + HideOnNextClick, + Dim(Rgba), +} + +#[derive(Clone, Debug)] +struct Effect { + timing: EffectTiming, + spec: EffectSpec, + after: After, +} + +/// A slide's animations laid out in time. +#[derive(Clone, Debug, Default)] +pub struct Timeline { + effects: Vec, + main: Vec, + triggers: Vec<(ShapeId, Vec)>, + /// Base fill colour per shape (for colour emphasis). + colors: Vec<(ShapeId, Rgba)>, +} + +enum Status { + NotStarted, + Active(f64), + Done, +} + +fn secs(ms: u32) -> f64 { + ms as f64 / 1000.0 +} + +fn finite_or(v: f64, d: f64) -> f64 { + if v.is_finite() { v } else { d } +} + +fn sanitize_xfrm(x: Xfrm, sw: f64, sh: f64) -> Xfrm { + Xfrm { + x: finite_or(x.x, 0.0).clamp(-1.0e7, 1.0e7), + y: finite_or(x.y, 0.0).clamp(-1.0e7, 1.0e7), + w: finite_or(x.w, sw / 2.0).clamp(0.0, 1.0e7), + h: finite_or(x.h, sh / 2.0).clamp(0.0, 1.0e7), + ..x + } +} + +fn default_scheme() -> Option { + slidecraft_model::theme::builtin_color_schemes().into_iter().next() +} + +impl Timeline { + /// Build the timeline of `slide` (slide size in points). Placeholders without their own box + /// get a centred default box; colours resolve against the default theme. + pub fn new(slide: &Slide, slide_w: f64, slide_h: f64) -> Timeline { + Timeline::with(slide, slide_w, slide_h, None, &|_| None) + } + + /// Build with a colour scheme for theme colours and a lookup for shape boxes (placeholders + /// inherit their box from the layout; the lookup wins over `shape.xfrm`). + pub fn with(slide: &Slide, slide_w: f64, slide_h: f64, scheme: Option<&ColorScheme>, xfrm_of: &dyn Fn(ShapeId) -> Option) -> Timeline { + let sw = finite_or(slide_w, 960.0).clamp(1.0, 1.0e7); + let sh = finite_or(slide_h, 540.0).clamp(1.0, 1.0e7); + let owned; + let scheme = match scheme { + Some(s) => Some(s), + None => { + owned = default_scheme(); + owned.as_ref() + } + }; + let resolve = |c: &slidecraft_model::ColorRef| -> Rgba { + match scheme { + Some(s) => c.resolve(s, None), + None => match &c.base { + slidecraft_model::ColorBase::Rgb { rgb } => *rgb, + _ => Rgba::rgb(0x15, 0x60, 0x82), + }, + } + }; + let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82)); + + let mut tl = Timeline::default(); + // Sequences in order of first appearance: main first. + let mut seqs: Vec = vec![Sequence::Main]; + for a in &slide.animations { + if let Some(tr) = a.trigger { + let s = Sequence::Trigger(tr); + if !seqs.contains(&s) { + seqs.push(s); + } + } + } + for seq in seqs { + let mut steps: Vec = Vec::new(); + let mut group_begin = 0.0f64; + let mut max_end = 0.0f64; + for (ai, a) in slide.animations.iter().enumerate() { + let aseq = a.trigger.map(Sequence::Trigger).unwrap_or(Sequence::Main); + if aseq != seq { + continue; + } + if tl.effects.len() >= MAX_EFFECTS { + break; + } + let shape = slide.shape(a.shape); + // Paragraph targets of this animation. + let paras: Vec> = match (a.paragraph, a.text_build) { + (Some(p), _) => vec![Some(p as usize)], + (None, TextBuild::ByParagraph) => { + let v: Vec> = shape + .and_then(|s| s.text.as_ref()) + .map(|t| { + t.paragraphs + .iter() + .enumerate() + .filter(|(_, p)| p.runs.iter().any(|r| !r.text.trim().is_empty())) + .map(|(i, _)| Some(i)) + .take(MAX_PARAGRAPHS) + .collect() + }) + .unwrap_or_default(); + if v.is_empty() { vec![None] } else { v } + } + _ => vec![None], + }; + let xf = xfrm_of(a.shape).or_else(|| shape.and_then(|s| s.xfrm)).unwrap_or(Xfrm::new(sw / 4.0, sh / 4.0, sw / 2.0, sh / 2.0)); + let xf = sanitize_xfrm(xf, sw, sh); + let spec = build_spec(a, xf, sw, sh, &resolve); + let dur = secs(a.duration_ms); + let delay = secs(a.delay_ms); + let until_next_click = a.repeat == u32::MAX; + let iterations = if until_next_click || a.repeat <= 1 { 1.0 } else { (a.repeat as f64).min(MAX_ITERATIONS) }; + let span = dur * if a.auto_reverse { 2.0 } else { 1.0 } * iterations; + let after = match a.after.as_deref() { + Some("hide") => After::Hide, + Some("hideOnNextClick") => After::HideOnNextClick, + Some(c) => Rgba::from_hex(c).map(After::Dim).unwrap_or(After::None), + None => After::None, + }; + let mut first_start = 0.0; + for (k, para) in paras.iter().enumerate() { + let mode = if k == 0 { + a.start + } else if a.start == AnimStart::OnClick { + AnimStart::OnClick + } else { + AnimStart::WithPrevious + }; + let first_of_seq = steps.is_empty(); + let mode = if first_of_seq && matches!(seq, Sequence::Trigger(_)) { AnimStart::OnClick } else { mode }; + match mode { + AnimStart::OnClick => { + steps.push(StepInfo { auto: false, duration: 0.0 }); + group_begin = 0.0; + max_end = 0.0; + } + AnimStart::WithPrevious => { + if first_of_seq { + steps.push(StepInfo { auto: true, duration: 0.0 }); + } + } + AnimStart::AfterPrevious => { + if first_of_seq { + steps.push(StepInfo { auto: true, duration: 0.0 }); + } + group_begin = max_end; + } + } + let start = if k > 0 && a.start != AnimStart::OnClick { first_start + k as f64 * PARAGRAPH_STAGGER } else { group_begin + delay }; + if k == 0 { + first_start = start; + } + let end = start + span; + max_end = max_end.max(end); + let step = steps.len().saturating_sub(1); + if let Some(s) = steps.last_mut() { + s.duration = s.duration.max(end); + } + tl.effects.push(Effect { + timing: EffectTiming { + anim: ai, + shape: a.shape, + paragraph: *para, + sequence: seq, + step, + start, + duration: dur, + iterations, + until_next_click, + auto_reverse: a.auto_reverse, + rewind: a.rewind, + span, + class: a.class, + }, + spec: spec.clone(), + after: after.clone(), + }); + } + } + match seq { + Sequence::Main => tl.main = steps, + Sequence::Trigger(s) => tl.triggers.push((s, steps)), + } + } + // Base colours of animated shapes. + for e in &tl.effects { + let id = e.timing.shape; + if tl.colors.iter().any(|(s, _)| *s == id) { + continue; + } + let c = match slide.shape(id).and_then(|s| s.fill.as_ref()) { + Some(Fill::Solid { color }) => resolve(color), + _ => accent, + }; + tl.colors.push((id, c)); + } + tl + } + + /// Number of steps in the main sequence (clicks, plus an automatic first step when the first + /// effect starts with/after previous). + pub fn steps(&self) -> usize { + self.main.len() + } + + /// Does main step `i` start by itself (no click), as the slide appears? + pub fn step_is_auto(&self, i: usize) -> bool { + self.main.get(i).is_some_and(|s| s.auto) + } + + /// Duration of main step `i` in seconds (0 when out of range). + pub fn step_duration(&self, i: usize) -> f64 { + self.main.get(i).map(|s| s.duration).unwrap_or(0.0) + } + + /// Total duration of the main sequence played without pauses. + pub fn total_duration(&self) -> f64 { + self.main.iter().map(|s| s.duration).sum() + } + + /// Map a time on the "play everything" clock to `(step, t)` for [`Timeline::state`]. + pub fn locate(&self, time: f64) -> (usize, f64) { + let mut t = finite_or(time, 0.0).max(0.0); + for (i, s) in self.main.iter().enumerate() { + if t < s.duration || (t == 0.0 && s.duration == 0.0 && i == 0) { + return (i, t); + } + t -= s.duration; + } + (self.main.len(), 0.0) + } + + /// Shapes that start an interactive sequence when clicked. + pub fn trigger_shapes(&self) -> Vec { + self.triggers.iter().map(|(s, _)| *s).collect() + } + + /// Steps (clicks on the trigger) of a triggered sequence. + pub fn trigger_steps(&self, trigger: ShapeId) -> usize { + self.triggers.iter().find(|(s, _)| *s == trigger).map(|(_, v)| v.len()).unwrap_or(0) + } + + pub fn trigger_step_duration(&self, trigger: ShapeId, i: usize) -> f64 { + self.triggers.iter().find(|(s, _)| *s == trigger).and_then(|(_, v)| v.get(i)).map(|s| s.duration).unwrap_or(0.0) + } + + /// All effect timings in sequence order. + pub fn effects(&self) -> Vec { + self.effects.iter().map(|e| e.timing.clone()).collect() + } + + /// Is anything animated on this slide? + pub fn is_empty(&self) -> bool { + self.effects.is_empty() + } + + /// Paragraph indices of `shape` animated separately (by-paragraph builds). + pub fn paragraph_targets(&self, shape: ShapeId) -> Vec { + let mut v: Vec = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect(); + v.sort_unstable(); + v.dedup(); + v + } + + /// Shapes with any effect. + pub fn animated_shapes(&self) -> Vec { + let mut v: Vec = Vec::new(); + for e in &self.effects { + if !v.contains(&e.timing.shape) { + v.push(e.timing.shape); + } + } + v + } + + /// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been + /// fully played and step `step` has been playing for `t` seconds (`t <= 0`: not started; + /// `step >= steps()`: everything done). Paragraph states are relative to the shape's own state + /// (compose them). Triggered sequences are considered not played. + pub fn state(&self, shape: ShapeId, para: Option, step: usize, t: f64) -> AnimState { + self.state_with(shape, para, step, t, &[]) + } + + /// [`Timeline::state`] with the playheads of triggered sequences. + pub fn state_with(&self, shape: ShapeId, para: Option, step: usize, t: f64, triggers: &[TriggerPlay]) -> AnimState { + let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para); + let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82)); + let mut st = AnimState::default(); + // Initial visibility comes from the first effect in the Animation Pane order. + let mut ordered: Vec<&Effect> = mine.collect(); + ordered.sort_by_key(|e| e.timing.anim); + if let Some(e0) = ordered.first() + && e0.timing.class == AnimClass::Entrance + { + st.visible = false; + } + // Then play in time order: main sequence first, then triggered sequences. + for e in &ordered { + let (pstep, pt) = match e.timing.sequence { + Sequence::Main => (step, t), + Sequence::Trigger(tr) => triggers.iter().find(|p| p.trigger == tr).map(|p| (p.step, p.t)).unwrap_or((0, 0.0)), + }; + let cur = st.tint.unwrap_or(base_color); + let revert = e.timing.rewind || e.timing.auto_reverse; + match status(&e.timing, pstep, pt) { + Status::NotStarted => {} + Status::Active(p) => { + if e.timing.class == AnimClass::Entrance || e.timing.class == AnimClass::Exit { + st.visible = true; + } + st = st.compose(&e.spec.eval(p, cur)); + } + Status::Done => { + match e.timing.class { + AnimClass::Entrance => st.visible = !revert, + AnimClass::Exit => st.visible = revert, + AnimClass::Emphasis | AnimClass::Path if !revert => { + let end = e.spec.end_state(cur); + st = st.compose(&end); + } + _ => {} + } + match e.after { + After::Hide => st.visible = false, + After::HideOnNextClick if pstep > e.timing.step => st.visible = false, + After::Dim(c) if pstep > e.timing.step => st.tint = Some(c), + _ => {} + } + } + } + } + st.sanitized() + } + + /// State on the "play everything" clock (Animation Pane ▸ Play All, preview). + pub fn state_at(&self, shape: ShapeId, para: Option, time: f64) -> AnimState { + let (s, t) = self.locate(time); + self.state(shape, para, s, t) + } +} + +fn status(e: &EffectTiming, step: usize, t: f64) -> Status { + if e.step < step { + return Status::Done; + } + if e.step > step { + return Status::NotStarted; + } + let t = finite_or(t, 0.0); + if t <= 0.0 { + return Status::NotStarted; + } + let local = t - e.start; + if local < 0.0 { + return Status::NotStarted; + } + let dur = e.duration; + if dur.is_nan() || dur <= 0.0 || !dur.is_finite() { + return Status::Done; + } + if !e.until_next_click && local >= e.span { + return Status::Done; + } + let period = if e.auto_reverse { 2.0 * dur } else { dur }; + let c = local % period; + let c = if c.is_finite() { c } else { 0.0 }; + let p = if c > dur { 2.0 - c / dur } else { c / dur }; + Status::Active(p.clamp(0.0, 1.0)) +} + +fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&slidecraft_model::ColorRef) -> Rgba) -> EffectSpec { + let effect = if a.class == AnimClass::Exit { exit_base(&a.effect).to_string() } else { a.effect.clone() }; + let path = if a.class == AnimClass::Path { Some(motion_path(&a.effect, &a.option, a.path.as_deref())) } else { None }; + EffectSpec { + class: a.class, + effect, + option: a.option.clone(), + accel: a.smooth_start, + decel: a.smooth_end, + bounce: a.bounce_end, + amount: a.amount, + color: a.color.as_ref().map(resolve), + path, + xf, + slide_w: sw, + slide_h: sh, + } +} diff --git a/crates/anim/src/transition.rs b/crates/anim/src/transition.rs new file mode 100644 index 0000000..008b906 --- /dev/null +++ b/crates/anim/src/transition.rs @@ -0,0 +1,907 @@ +//! Slide transitions as layers of textured quads. +//! +//! A frame is drawn over black: each [`Layer`] maps part of a source image (old slide, new slide, +//! or a solid colour) onto a quad in slide-unit space (0..1, y down). Fake 3-D comes from +//! trapezoids; wedges are triangles (4th corner repeats the 3rd). + +use std::f64::consts::PI; + +use serde::{Deserialize, Serialize}; + +use crate::clampf; +use crate::easing::smooth; + +/// What a layer shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Source { + /// The outgoing slide. + Old, + /// The incoming slide. + New, + Black, + White, +} + +/// One textured quad of a transition frame. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Layer { + pub source: Source, + /// Source sub-rectangle in uv space (0..1): l, t, r, b (bounds of `uv`). + pub src: [f64; 4], + /// Destination corners in slide-unit space: tl, tr, br, bl. + pub quad: [[f64; 2]; 4], + /// Texture coordinate of each destination corner (tl, tr, br, bl). For plain rectangles these + /// are the corners of `src`; triangles and mirrored flaps carry their own. Draw with these. + pub uv: [[f64; 2]; 4], + pub alpha: f64, +} + +type R = [f64; 4]; +const FULL: R = [0.0, 0.0, 1.0, 1.0]; + +fn corners(r: R) -> [[f64; 2]; 4] { + [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] +} + +impl Layer { + /// The whole source over the whole slide. + pub fn full(source: Source, alpha: f64) -> Layer { + Layer::rect(source, FULL, FULL, alpha) + } + /// `src` rectangle of the source drawn into the `dst` rectangle. + pub fn rect(source: Source, src: R, dst: R, alpha: f64) -> Layer { + Layer { source, src, quad: corners(dst), uv: corners(src), alpha } + } + /// `src` rectangle of the source drawn onto a quad. + pub fn quad(source: Source, src: R, quad: [[f64; 2]; 4], alpha: f64) -> Layer { + Layer { source, src, quad, uv: corners(src), alpha } + } + /// The part of the source under `dst` drawn in place. + fn inplace(source: Source, dst: R, alpha: f64) -> Layer { + Layer::rect(source, dst, dst, alpha) + } + /// A triangle of the source drawn in place. + fn tri(source: Source, a: [f64; 2], b: [f64; 2], c: [f64; 2], alpha: f64) -> Layer { + let xs = [a[0], b[0], c[0]]; + let ys = [a[1], b[1], c[1]]; + let mn = |v: [f64; 3]| v.iter().copied().fold(f64::INFINITY, f64::min); + let mx = |v: [f64; 3]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max); + Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: [a, b, c, c], uv: [a, b, c, c], alpha } + } + fn sanitized(mut self) -> Layer { + let f = |v: f64| if v.is_finite() { v.clamp(-1.0e6, 1.0e6) } else { 0.0 }; + for v in self.src.iter_mut() { + *v = f(*v).clamp(0.0, 1.0); + } + for c in self.quad.iter_mut().chain(self.uv.iter_mut()) { + c[0] = f(c[0]); + c[1] = f(c[1]); + } + for c in self.uv.iter_mut() { + c[0] = c[0].clamp(0.0, 1.0); + c[1] = c[1].clamp(0.0, 1.0); + } + self.alpha = clampf(self.alpha, 0.0, 1.0, 0.0); + self + } +} + +// ---------- geometry helpers ---------- + +fn offset(r: R, dx: f64, dy: f64) -> R { + [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy] +} + +fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer { + Layer::rect(source, FULL, offset(FULL, dx, dy), alpha) +} + +/// The whole source scaled about (cx, cy) and moved. +fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer { + let r = [cx + (0.0 - cx) * s + dx, cy + (0.0 - cy) * s + dy, cx + (1.0 - cx) * s + dx, cy + (1.0 - cy) * s + dy]; + Layer::rect(source, FULL, r, alpha) +} + +/// Rotate a point about a pivot (degrees). +fn rot(p: [f64; 2], c: [f64; 2], deg: f64) -> [f64; 2] { + let (s, co) = deg.to_radians().sin_cos(); + let (x, y) = (p[0] - c[0], p[1] - c[1]); + [c[0] + x * co - y * s, c[1] + x * s + y * co] +} + +fn rot_quad(q: [[f64; 2]; 4], c: [f64; 2], deg: f64) -> [[f64; 2]; 4] { + [rot(q[0], c, deg), rot(q[1], c, deg), rot(q[2], c, deg), rot(q[3], c, deg)] +} + +/// Horizontal perspective: left/right edges shortened by `li`/`ri` (fraction of height, each end). +fn persp_h(r: R, li: f64, ri: f64) -> [[f64; 2]; 4] { + let h = r[3] - r[1]; + [[r[0], r[1] + li * h], [r[2], r[1] + ri * h], [r[2], r[3] - ri * h], [r[0], r[3] - li * h]] +} + +/// Vertical perspective: top/bottom edges shortened by `ti`/`bi` (fraction of width, each end). +fn persp_v(r: R, ti: f64, bi: f64) -> [[f64; 2]; 4] { + let w = r[2] - r[0]; + [[r[0] + ti * w, r[1]], [r[2] - ti * w, r[1]], [r[2] - bi * w, r[3]], [r[0] + bi * w, r[3]]] +} + +/// Mirror a rectangle horizontally and/or vertically, or swap its axes (canonical → direction). +fn map_rect(r: R, mx: bool, my: bool, swap: bool) -> R { + let mut r = if swap { [r[1], r[0], r[3], r[2]] } else { r }; + if mx { + r = [1.0 - r[2], r[1], 1.0 - r[0], r[3]]; + } + if my { + r = [r[0], 1.0 - r[3], r[2], 1.0 - r[1]]; + } + r +} + +/// Motion direction of an option: l, r, u, d and diagonals. +fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) { + let mut d = (0.0, 0.0); + if opt.contains('l') { + d.0 = -1.0; + } + if opt.contains('r') { + d.0 = 1.0; + } + if opt.contains('u') { + d.1 = -1.0; + } + if opt.contains('d') { + d.1 = 1.0; + } + if d == (0.0, 0.0) { default } else { d } +} + +fn hash64(mut x: u64) -> u64 { + x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + x ^ (x >> 31) +} + +/// Deterministic pseudo-random value in [0, 1). +fn rnd(i: u64) -> f64 { + (hash64(i) >> 11) as f64 / (1u64 << 53) as f64 +} + +fn ramp(t: f64, order: f64, window: f64) -> f64 { + let w = window.clamp(0.01, 1.0); + ((t - order * (1.0 - w)) / w).clamp(0.0, 1.0) +} + +fn grid(cols: usize, rows: usize) -> Vec<(usize, usize, R)> { + let mut v = Vec::with_capacity(cols * rows); + for r in 0..rows { + for c in 0..cols { + let cell = [c as f64 / cols as f64, r as f64 / rows as f64, (c + 1) as f64 / cols as f64, (r + 1) as f64 / rows as f64]; + v.push((c, r, cell)); + } + } + v +} + +fn center(r: R) -> [f64; 2] { + [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0] +} + +fn scale_rect(r: R, s: f64) -> R { + let c = center(r); + let hw = (r[2] - r[0]) / 2.0 * s; + let hh = (r[3] - r[1]) / 2.0 * s; + [c[0] - hw, c[1] - hh, c[0] + hw, c[1] + hh] +} + +// ---------- effects ---------- + +/// Layers of the transition `kind` (a [`slidecraft_model::anim::TRANSITIONS`] id) with `option` at +/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone; +/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph +/// frames with [`crate::morph_pairs`]). +pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec { + let t = clampf(t, 0.0, 1.0, 0.0); + let v = if t >= 1.0 { + vec![Layer::full(Source::New, 1.0)] + } else if t <= 0.0 { + vec![Layer::full(Source::Old, 1.0)] + } else { + layers(kind, option, t, 0) + }; + v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0).collect() +} + +fn crossfade(t: f64) -> Vec { + vec![Layer::full(Source::Old, 1.0), Layer::full(Source::New, t)] +} + +const RANDOM_KINDS: &[&str] = &[ + "fade", + "push", + "wipe", + "split", + "reveal", + "randomBar", + "shape", + "uncover", + "cover", + "fallOver", + "drape", + "curtains", + "wind", + "prestige", + "fracture", + "crush", + "peelOff", + "pageCurlDouble", + "airplane", + "origami", + "dissolve", + "checker", + "blinds", + "clock", + "ripple", + "honeycomb", + "glitter", + "vortex", + "shred", + "switch", + "flip", + "gallery", + "cube", + "doors", + "box", + "comb", + "zoom", + "pan", + "ferris", + "conveyor", + "rotate", + "window", + "orbit", + "flythrough", +]; + +fn layers(kind: &str, option: &str, t: f64, depth: u32) -> Vec { + use Source::{Black, New, Old, White}; + let o = option; + let s = smooth(t); + match kind { + "none" => vec![Layer::full(New, 1.0)], + "cut" => { + if o == "throughBlack" && t < 0.5 { + vec![Layer::full(Black, 1.0)] + } else { + vec![Layer::full(New, 1.0)] + } + } + "fade" if o == "throughBlack" => { + if t < 0.5 { + vec![Layer::full(Black, 1.0), Layer::full(Old, 1.0 - 2.0 * t)] + } else { + vec![Layer::full(Black, 1.0), Layer::full(New, 2.0 * t - 1.0)] + } + } + "fade" | "morph" => crossfade(t), + "push" | "pan" => { + let (dx, dy) = dir(o, (0.0, -1.0)); + vec![moved(Old, dx * s, dy * s, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)] + } + "uncover" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + vec![Layer::full(New, 1.0), moved(Old, dx * s, dy * s, 1.0)] + } + "cover" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + vec![Layer::full(Old, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)] + } + "wipe" => wipe(o, t), + "split" => { + let horz = o.starts_with("horz"); + let mut v = vec![Layer::full(Old, 1.0)]; + let rects: Vec = if o.ends_with("In") { + vec![[0.0, 0.0, s / 2.0, 1.0], [1.0 - s / 2.0, 0.0, 1.0, 1.0]] + } else { + vec![[0.5 - s / 2.0, 0.0, 0.5 + s / 2.0, 1.0]] + }; + for r in rects { + v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0)); + } + v + } + "reveal" => { + let dx = if o.ends_with("Right") { 1.0 } else { -1.0 }; + if o.starts_with("black") { + if t < 0.5 { + vec![Layer::full(Black, 1.0), moved(Old, dx * t * 0.4, 0.0, 1.0 - 2.0 * t)] + } else { + vec![Layer::full(Black, 1.0), moved(New, -dx * (1.0 - t) * 0.2, 0.0, 2.0 * t - 1.0)] + } + } else { + vec![Layer::full(New, 1.0), moved(Old, dx * s * 0.6, 0.0, 1.0 - t)] + } + } + "randomBar" => { + let horz = o == "horz"; + let n = 48; + let mut v = vec![Layer::full(Old, 1.0)]; + for i in 0..n { + if t > rnd(i as u64 + 17) * 0.98 { + let r = [i as f64 / n as f64, 0.0, (i + 1) as f64 / n as f64, 1.0]; + v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0)); + } + } + v + } + "shape" => { + let form = match o { + "circle" | "diamond" | "plus" => o, + _ => "box", + }; + let inward = o == "in"; + if inward { + let mut v = vec![Layer::full(New, 1.0)]; + v.extend(shape_form(Old, form, 1.0 - s)); + v + } else { + let mut v = vec![Layer::full(Old, 1.0)]; + v.extend(shape_form(New, form, s)); + v + } + } + "flash" => { + if t < 0.5 { + vec![Layer::full(Old, 1.0), Layer::full(White, (2.0 * t).min(1.0))] + } else { + vec![Layer::full(New, 1.0), Layer::full(White, 2.0 - 2.0 * t)] + } + } + "fallOver" => { + let sg = if o == "r" { 1.0 } else { -1.0 }; + let a = t * t * PI / 2.0; + let top = 1.0 - a.cos(); + let ins = 0.12 * a.sin(); + let sh = sg * 0.15 * a.sin(); + let q = [[ins + sh, top], [1.0 - ins + sh, top], [1.0, 1.0], [0.0, 1.0]]; + vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t.powi(3))] + } + "drape" => { + let k = t * t; + let q = [[0.0, 0.0], [1.0 - 0.3 * k, k], [1.0 - 0.3 * k, 1.0 + k], [0.0, 1.0]]; + let q = if o == "r" { mirror_q(q) } else { q }; + vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - k)] + } + "curtains" => { + let w = 0.5 * (1.0 - s); + let lq = [[0.0, 0.0], [w, 0.12 * s], [w, 1.0 - 0.12 * s], [0.0, 1.0]]; + let rq = [[1.0 - w, 0.12 * s], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 - 0.12 * s]]; + vec![ + scaled(New, 0.9 + 0.1 * s, 0.5, 0.5, 0.0, 0.0, 1.0), + Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], lq, 1.0), + Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], rq, 1.0), + ] + } + "wind" => { + let d = if o == "r" { 1.0 } else { -1.0 }; + let r = offset(FULL, d * 1.2 * s, -0.1 * s); + let q = [[r[0] + d * 0.2 * s, r[1]], [r[2] + d * 0.2 * s, r[1]], [r[2], r[3]], [r[0], r[3]]]; + vec![moved(New, -d * 0.3 * (1.0 - s), 0.0, smooth((t * 1.5).min(1.0))), Layer::quad(Old, FULL, q, 1.0 - t)] + } + "prestige" => vec![scaled(Old, 1.0 - 0.3 * s, 0.5, 0.5, 0.0, 0.2 * s, 1.0 - t), scaled(New, 0.7 + 0.3 * s, 0.5, 0.5, 0.0, 0.0, s)], + "fracture" => { + let mut v = vec![Layer::full(New, 1.0)]; + for (c, r, cell) in grid(8, 6) { + let i = (r * 8 + c) as u64; + let q = ramp(t, rnd(i + 101) * 0.5, 0.5); + let cc = center(cell); + let (dx, dy) = ((cc[0] - 0.5) * 1.5 * q, (cc[1] - 0.5) * 1.5 * q + 0.5 * q * q); + let quad = rot_quad(corners(offset(cell, dx, dy)), [cc[0] + dx, cc[1] + dy], (rnd(i + 7) - 0.5) * 180.0 * q); + v.push(Layer::quad(Old, cell, quad, 1.0 - q)); + } + v + } + "crush" => { + let mut v = vec![Layer::full(New, 1.0)]; + for (c, r, cell) in grid(4, 4) { + let i = (r * 4 + c) as u64; + let cc = center(cell); + let k = 1.0 - s; + let pos = [0.5 + (cc[0] - 0.5) * k, 0.5 + (cc[1] - 0.5) * k]; + let half = scale_rect(cell, k * (0.8 + 0.2 * rnd(i + 3))); + let half = offset(half, pos[0] - cc[0], pos[1] - cc[1]); + let quad = rot_quad(corners(half), pos, (rnd(i + 5) - 0.5) * 60.0 * s); + v.push(Layer::quad(Old, cell, quad, 1.0 - t * t)); + } + v + } + "peelOff" => { + let mut v = vec![Layer::full(New, 1.0)]; + v.extend(peel(Old, [0.0, 0.0, 1.0, 1.0], s, o == "r")); + v + } + "pageCurlDouble" => { + let mut v = vec![Layer::full(New, 1.0)]; + let left_first = o != "r"; + v.extend(peel(Old, [0.0, 0.0, 0.5, 1.0], s, !left_first)); + v.extend(peel(Old, [0.5, 0.0, 1.0, 1.0], s, left_first)); + v + } + "airplane" => { + let d = if o == "r" { 1.0 } else { -1.0 }; + let k = 1.0 - 0.8 * s; + let r = offset(scale_rect(FULL, k), d * 1.2 * s * s, -0.5 * s * s); + let q = persp_h(r, if d > 0.0 { 0.3 * s } else { 0.0 }, if d > 0.0 { 0.0 } else { 0.3 * s }); + vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t * t)] + } + "origami" => { + let d = if o == "r" { 1.0 } else { -1.0 }; + let mut v = vec![Layer::full(New, 1.0)]; + if t < 0.5 { + let f = t * 2.0; + let w = 0.5 * (1.0 - 0.8 * f); + v.push(Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], persp_h([0.5 - w, 0.0, 0.5, 1.0], 0.15 * f, 0.0), 1.0)); + v.push(Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], persp_h([0.5, 0.0, 0.5 + w, 1.0], 0.0, 0.15 * f), 1.0)); + } else { + let f = (t - 0.5) * 2.0; + let r = offset(scale_rect([0.4, 0.0, 0.6, 1.0], 1.0 - 0.6 * f), d * f * 0.8, -0.3 * f); + v.push(Layer::quad(Old, FULL, persp_v(r, 0.1, 0.0), 1.0 - f)); + } + v + } + "dissolve" => { + let mut v = vec![Layer::full(Old, 1.0)]; + for (c, r, cell) in grid(32, 18) { + if t > rnd((r * 32 + c) as u64 + 991) * 0.98 { + v.push(Layer::inplace(New, cell, 1.0)); + } + } + v + } + "checker" => { + let vert = o == "vert"; + let (n, m) = (10usize, 8usize); + let mut v = vec![Layer::full(Old, 1.0)]; + for (c, r, cell) in grid(n, m) { + let across = if vert { c } else { r }; + let f = (t * 2.0 - (across % 2) as f64 * 0.5).clamp(0.0, 1.0); + if f <= 0.0 { + continue; + } + let rc = if vert { + [cell[0], cell[1], cell[2], cell[1] + (cell[3] - cell[1]) * f] + } else { + [cell[0], cell[1], cell[0] + (cell[2] - cell[0]) * f, cell[3]] + }; + v.push(Layer::inplace(New, rc, 1.0)); + } + v + } + "blinds" => { + let horz = o == "horz"; + let n = 8; + let mut v = vec![Layer::full(Old, 1.0)]; + for i in 0..n { + let x0 = i as f64 / n as f64; + let r = [x0, 0.0, x0 + s / n as f64, 1.0]; + v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0)); + } + v + } + "clock" => clock(o, s), + "ripple" => { + let origin = match o { + "lu" => [0.0, 0.0], + "ru" => [1.0, 0.0], + "ld" => [0.0, 1.0], + "rd" => [1.0, 1.0], + _ => [0.5, 0.5], + }; + let maxd = if origin == [0.5, 0.5] { 0.71 } else { 1.42 }; + let mut v = vec![Layer::full(Old, 1.0)]; + for (_, _, cell) in grid(24, 14) { + let cc = center(cell); + let d = ((cc[0] - origin[0]).powi(2) + (cc[1] - origin[1]).powi(2)).sqrt() / maxd; + let a = ramp(t, d.min(1.0), 0.3); + if a > 0.0 { + let sc = 1.0 + 0.15 * (a * PI).sin(); + v.push(Layer::rect(New, cell, scale_rect(cell, sc), a)); + } + } + v + } + "honeycomb" => { + let (cols, rows) = (12usize, 8usize); + let mut v = vec![Layer::full(Old, 1.0)]; + for c in 0..cols { + let odd = c % 2 == 1; + let extra = if odd { 1 } else { 0 }; + for r in 0..rows + extra { + let y0 = (r as f64 - if odd { 0.5 } else { 0.0 }) / rows as f64; + let cell = [c as f64 / cols as f64, y0.max(0.0), (c + 1) as f64 / cols as f64, (y0 + 1.0 / rows as f64).min(1.0)]; + let a = ramp(t, rnd((c * 64 + r) as u64 + 4242), 0.35); + if a > 0.0 { + v.push(Layer::rect(New, cell, scale_rect(cell, 0.3 + 0.7 * a), a)); + } + } + } + v + } + "glitter" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + let mut v = vec![Layer::full(Old, 1.0)]; + for (c, r, cell) in grid(20, 12) { + let cc = center(cell); + let grad = if dx < 0.0 { + 1.0 - cc[0] + } else if dx > 0.0 { + cc[0] + } else if dy < 0.0 { + 1.0 - cc[1] + } else { + cc[1] + }; + let order = 0.6 * grad + 0.4 * rnd((r * 20 + c) as u64 + 77); + let a = ramp(t, order, 0.25); + if a > 0.0 { + let quad = rot_quad(corners(cell), cc, (1.0 - a) * 90.0); + v.push(Layer::quad(New, cell, quad, a)); + } + } + v + } + "vortex" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + let mut v = vec![Layer::full(New, 1.0)]; + for (c, r, cell) in grid(16, 9) { + let i = (r * 16 + c) as u64; + let cc = center(cell); + let grad = 0.5 + 0.5 * ((cc[0] - 0.5) * -dx + (cc[1] - 0.5) * -dy); + let q = ramp(t, 0.5 * grad + 0.5 * rnd(i + 31), 0.4); + let ang = q * 2.0 * PI + rnd(i) * PI; + let rad = q * 1.2; + let (ox, oy) = (dx * q * 0.8 + rad * ang.cos() * 0.3, dy * q * 0.8 + rad * ang.sin() * 0.3); + let pos = [cc[0] + ox, cc[1] + oy]; + let quad = rot_quad(corners(offset(scale_rect(cell, 1.0 - 0.5 * q), ox, oy)), pos, q * 360.0); + v.push(Layer::quad(Old, cell, quad, 1.0 - q)); + } + v + } + "shred" => { + let mut v = vec![Layer::full(New, 1.0)]; + if o == "particles" { + for (c, r, cell) in grid(24, 14) { + let i = (r * 24 + c) as u64; + let q = ramp(t, rnd(i + 555) * 0.6 + 0.4 * (1.0 - center(cell)[1]), 0.4); + let dy = q * q * 1.2; + let dx = (rnd(i + 9) - 0.5) * 0.3 * q; + v.push(Layer::rect(Old, cell, offset(scale_rect(cell, 1.0 - 0.6 * q), dx, dy), 1.0 - q)); + } + } else { + let n = 16; + for i in 0..n { + let x0 = i as f64 / n as f64; + let x1 = (i + 1) as f64 / n as f64; + let q = ramp(t, i as f64 / n as f64 * 0.3 + 0.1 * rnd(i as u64), 0.7); + let dy = if i % 2 == 0 { 1.1 * q * q } else { -1.1 * q * q }; + v.push(Layer::rect(Old, [x0, 0.0, x1, 1.0], [x0, dy, x1, 1.0 + dy], 1.0)); + } + } + v + } + "switch" => { + let d = if o == "r" { 1.0 } else { -1.0 }; + let k = (t * PI).sin(); + let old = Layer::quad( + Old, + FULL, + persp_h( + offset(scale_rect(FULL, 1.0 - 0.25 * t), d * 0.45 * k, 0.0), + if d > 0.0 { 0.0 } else { 0.08 * k }, + if d > 0.0 { 0.08 * k } else { 0.0 }, + ), + 1.0, + ); + let new = Layer::quad( + New, + FULL, + persp_h( + offset(scale_rect(FULL, 0.75 + 0.25 * t), -d * 0.45 * k, 0.0), + if d > 0.0 { 0.08 * k } else { 0.0 }, + if d > 0.0 { 0.0 } else { 0.08 * k }, + ), + 1.0, + ); + if t < 0.5 { vec![new, old] } else { vec![old, new] } + } + "flip" => { + let a = PI * s; + let w = a.cos().abs(); + let k = 0.1 * a.sin(); + let r = [0.5 - w / 2.0, 0.0, 0.5 + w / 2.0, 1.0]; + let right = o != "l"; + if t < 0.5 { + let q = if right { persp_h(r, -k / 2.0, k) } else { persp_h(r, k, -k / 2.0) }; + vec![Layer::quad(Old, FULL, q, 1.0)] + } else { + let q = if right { persp_h(r, k, -k / 2.0) } else { persp_h(r, -k / 2.0, k) }; + vec![Layer::quad(New, FULL, q, 1.0)] + } + } + "gallery" | "conveyor" => { + let d = if o == "r" { 1.0 } else { -1.0 }; + let k = (s * PI).sin(); + let sc = 1.0 - if kind == "gallery" { 0.25 } else { 0.1 } * k; + let gap = if kind == "gallery" { 1.15 } else { 1.05 }; + let old = offset(scale_rect(FULL, sc), d * s * gap, 0.0); + let new = offset(scale_rect(FULL, sc), d * (s - 1.0) * gap, 0.0); + let p = 0.08 * k; + vec![ + Layer::quad(Old, FULL, if d > 0.0 { persp_h(old, 0.0, p) } else { persp_h(old, p, 0.0) }, 1.0), + Layer::quad(New, FULL, if d > 0.0 { persp_h(new, p, 0.0) } else { persp_h(new, 0.0, p) }, 1.0), + ] + } + "cube" | "box" | "rotate" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + let vertical = dy != 0.0 && dx == 0.0; + let neg = if vertical { dy < 0.0 } else { dx < 0.0 }; + let k = (s * PI).sin(); + let (far_old, far_new, shared) = match kind { + "cube" => (0.18 * s, 0.18 * (1.0 - s), 0.05 * k), + "box" => (0.0, 0.0, 0.18 * k), + _ => (0.25 * s, 0.25 * (1.0 - s), 0.0), + }; + // Canonical: moving toward -x; old on the left [0, 1-s], new on the right [1-s, 1]. + let split = 1.0 - s; + let (old_q, new_q) = if kind == "rotate" { + if t < 0.5 { + (Some(canon_q([0.0, 0.0, (1.0 - 2.0 * t).max(0.0), 1.0], far_old, 0.0)), None) + } else { + (None, Some(canon_q([1.0 - (2.0 * t - 1.0), 0.0, 1.0, 1.0], 0.0, far_new))) + } + } else { + (Some(canon_q([0.0, 0.0, split, 1.0], far_old, shared)), Some(canon_q([split, 0.0, 1.0, 1.0], shared, far_new))) + }; + let fix = |q: [[f64; 2]; 4]| -> [[f64; 2]; 4] { + let q = if neg { q } else { mirror_q(q) }; + if vertical { swap_q(q) } else { q } + }; + let mut v = vec![Layer::full(Black, 1.0)]; + if let Some(q) = old_q { + v.push(Layer::quad(Old, FULL, fix(q), 1.0)); + } + if let Some(q) = new_q { + v.push(Layer::quad(New, FULL, fix(q), 1.0)); + } + v + } + "doors" | "window" => { + let horz = o == "horz"; + let (zoom0, persp) = if kind == "doors" { (0.85, 0.1) } else { (0.6, 0.2) }; + let w = 0.5 * (1.0 - s); + let k = persp * s; + let left = [[0.0, 0.0], [w, -k], [w, 1.0 + k], [0.0, 1.0]]; + let right = [[1.0 - w, -k], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 + k]]; + let (l, r) = if horz { (swap_q(left), swap_q(right)) } else { (left, right) }; + let (sl, sr) = if horz { ([0.0, 0.0, 1.0, 0.5], [0.0, 0.5, 1.0, 1.0]) } else { ([0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]) }; + vec![scaled(New, zoom0 + (1.0 - zoom0) * s, 0.5, 0.5, 0.0, 0.0, s.max(0.2)), Layer::quad(Old, sl, l, 1.0), Layer::quad(Old, sr, r, 1.0)] + } + "comb" => { + let vert = o == "vert"; + let n = 8; + let mut v = Vec::new(); + for i in 0..n { + let a = i as f64 / n as f64; + let b = (i + 1) as f64 / n as f64; + let d = if i % 2 == 0 { 1.0 } else { -1.0 }; + let band = if vert { [a, 0.0, b, 1.0] } else { [0.0, a, 1.0, b] }; + let (ox, oy) = if vert { (0.0, d * s) } else { (d * s, 0.0) }; + let (nx, ny) = if vert { (0.0, -d * (1.0 - s)) } else { (-d * (1.0 - s), 0.0) }; + v.push(Layer::rect(Old, band, offset(band, ox, oy), 1.0)); + v.push(Layer::rect(New, band, offset(band, nx, ny), 1.0)); + } + v + } + "zoom" => { + if o == "out" { + vec![scaled(New, 1.5 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 - 0.7 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)] + } else { + vec![scaled(Old, 1.0 + s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, s.max(0.01), 0.5, 0.5, 0.0, 0.0, s)] + } + } + "orbit" => { + let (dx, dy) = dir(o, (-1.0, 0.0)); + let k = (s * PI).sin(); + let old = scaled(Old, 1.0 - 0.4 * k, 0.5, 0.5, dx * s + dy.abs() * 0.15 * k, dy * s + dx.abs() * 0.15 * k, 1.0 - 0.5 * s); + let new = scaled(New, 1.0 - 0.4 * k, 0.5, 0.5, dx * (s - 1.0) - dy.abs() * 0.15 * k, dy * (s - 1.0) - dx.abs() * 0.15 * k, 0.5 + 0.5 * s); + if t < 0.5 { vec![new, old] } else { vec![old, new] } + } + "flythrough" => { + let bounce = o.ends_with("Bounce"); + let ns = |base: f64| if bounce { base + 0.08 * (s * PI).sin() } else { base }; + if o.starts_with("out") { + vec![scaled(Old, 1.0 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, ns(1.5 - 0.5 * s), 0.5, 0.5, 0.0, 0.0, s)] + } else { + vec![scaled(New, ns(0.5 + 0.5 * s), 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 + 1.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)] + } + } + "ferris" => { + let d = if o == "r" { 1.0 } else { -1.0 }; + let pivot = [0.5, 2.5]; + let a = 50.0 * s * d; + let oq = rot_quad(corners(FULL), pivot, a); + let nq = rot_quad(corners(FULL), pivot, a - 50.0 * d); + vec![Layer::quad(Old, FULL, oq, 1.0), Layer::quad(New, FULL, nq, 1.0)] + } + "random" if depth == 0 => { + let h = option.bytes().fold(0x51ED_u64, |h, b| hash64(h ^ b as u64)); + let k = RANDOM_KINDS.get((h % RANDOM_KINDS.len() as u64) as usize).copied().unwrap_or("fade"); + let opt = slidecraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == k).and_then(|x| x.4.first().copied()).unwrap_or(""); + layers(k, opt, t, depth + 1) + } + _ => crossfade(t), + } +} + +fn mirror_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] { + // Mirror x and keep the tl, tr, br, bl order (swap left/right corners). + let m = |p: [f64; 2]| [1.0 - p[0], p[1]]; + [m(q[1]), m(q[0]), m(q[3]), m(q[2])] +} + +fn swap_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] { + // Transpose x/y; corners reordered so tl, tr, br, bl still map to the source's corners. + let s = |p: [f64; 2]| [p[1], p[0]]; + [s(q[0]), s(q[3]), s(q[2]), s(q[1])] +} + +fn canon_q(r: R, left_inset: f64, right_inset: f64) -> [[f64; 2]; 4] { + persp_h(r, left_inset, right_inset) +} + +fn wipe(o: &str, t: f64) -> Vec { + let mut v = vec![Layer::full(Source::Old, 1.0)]; + let diag = o.len() == 2; + if diag { + // Diagonal: columns revealed by a slanted edge. + let (mx, my) = (o.contains('l'), o.contains('u')); + let n = 32; + for i in 0..n { + let x0 = i as f64 / n as f64; + let x1 = (i + 1) as f64 / n as f64; + let h = (2.0 * t - (x0 + x1) / 2.0).clamp(0.0, 1.0); + if h > 0.0 { + v.push(Layer::inplace(Source::New, map_rect([x0, 0.0, x1, h], mx, my, false), 1.0)); + } + } + return v; + } + let (mx, swap) = match o { + "l" => (true, false), + "d" => (false, true), + "u" => (true, true), + _ => (false, false), + }; + let soft = 0.15; + let e = t * (1.0 + soft); + let solid = (e - soft).clamp(0.0, 1.0); + if solid > 0.0 { + v.push(Layer::inplace(Source::New, map_rect([0.0, 0.0, solid, 1.0], mx, false, swap), 1.0)); + } + let n = 6; + for k in 0..n { + let a = e - soft + soft * k as f64 / n as f64; + let b = e - soft + soft * (k + 1) as f64 / n as f64; + let (a, b) = (a.clamp(0.0, 1.0), b.clamp(0.0, 1.0)); + if b > a { + let alpha = 1.0 - (k as f64 + 0.5) / n as f64; + v.push(Layer::inplace(Source::New, map_rect([a, 0.0, b, 1.0], mx, false, swap), alpha)); + } + } + v +} + +/// A centred shape of the source at size `f` (0 = nothing, 1 = covers the slide), as strips. +fn shape_form(src: Source, form: &str, f: f64) -> Vec { + let f = f.clamp(0.0, 1.0); + if f <= 0.0 { + return vec![]; + } + match form { + "plus" => { + let h = f / 2.0; + vec![Layer::inplace(src, [0.0, 0.5 - h, 1.0, 0.5 + h], 1.0), Layer::inplace(src, [0.5 - h, 0.0, 0.5 + h, 1.0], 1.0)] + } + "circle" | "diamond" => { + let n = 40; + let r = if form == "circle" { f * 0.75 } else { f * 1.02 }; + let mut v = Vec::new(); + for i in 0..n { + let y0 = i as f64 / n as f64; + let y1 = (i + 1) as f64 / n as f64; + let dy = ((y0 + y1) / 2.0 - 0.5).abs(); + let half = if form == "circle" { if r > dy { (r * r - dy * dy).sqrt() } else { 0.0 } } else { r - dy }; + if half > 0.0 { + let half = half.min(0.5); + v.push(Layer::inplace(src, [0.5 - half, y0, 0.5 + half, y1], 1.0)); + } + } + v + } + _ => vec![Layer::inplace(src, scale_rect(FULL, f), 1.0)], + } +} + +/// A peeling page: the part of `area` not yet peeled stays, the peeled part shows as a mirrored flap. +fn peel(src: Source, area: R, s: f64, toward_right: bool) -> Vec { + let w = area[2] - area[0]; + let p = s * w; + if toward_right { + let edge = area[0] + p; + let stay = [edge, area[1], area[2], area[3]]; + let flap_src = [area[0], area[1], edge, area[3]]; + let x2 = edge + p; + let flap = Layer { + source: src, + src: flap_src, + quad: [[x2, area[1]], [edge, area[1]], [edge, area[3]], [x2, area[3]]], + uv: corners(flap_src), + alpha: 0.85, + }; + vec![Layer::inplace(src, stay, 1.0), flap] + } else { + let edge = area[2] - p; + let stay = [area[0], area[1], edge, area[3]]; + let flap_src = [edge, area[1], area[2], area[3]]; + let x2 = edge - p; + let flap = Layer { + source: src, + src: flap_src, + quad: [[edge, area[1]], [x2, area[1]], [x2, area[3]], [edge, area[3]]], + uv: corners(flap_src), + alpha: 0.85, + }; + vec![Layer::inplace(src, stay, 1.0), flap] + } +} + +/// Point on the unit square's boundary seen from the centre at angle `a` (radians, clockwise from +/// 12 o'clock). +fn boundary(a: f64) -> [f64; 2] { + let (dx, dy) = (a.sin(), -a.cos()); + let m = dx.abs().max(dy.abs()).max(1e-9); + [0.5 + 0.5 * dx / m, 0.5 + 0.5 * dy / m] +} + +fn clock(o: &str, s: f64) -> Vec { + let mut v = vec![Layer::full(Source::Old, 1.0)]; + let c = [0.5, 0.5]; + let sweep = s * 2.0 * PI; + let (a0, a1) = match o { + "counterClockwise" => (-sweep, 0.0), + "wedge" => (-sweep / 2.0, sweep / 2.0), + _ => (0.0, sweep), + }; + let span = a1 - a0; + if span <= 0.0 { + return v; + } + let n = ((span / (PI / 24.0)).ceil() as usize).clamp(1, 96); + let mut angles: Vec = (0..=n).map(|k| a0 + span * k as f64 / n as f64).collect(); + // Square corners inside the sweep, so the fan reaches them. + for k in -8i32..=8 { + let a = PI / 4.0 + k as f64 * PI / 2.0; + if a > a0 && a < a1 { + angles.push(a); + } + } + angles.sort_by(f64::total_cmp); + for w in angles.windows(2) { + if let [x, y] = w + && y > x + { + v.push(Layer::tri(Source::New, c, boundary(*x), boundary(*y), 1.0)); + } + } + v +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index fa5c3a0..2af8515 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -50,6 +50,34 @@ pub fn specs() -> Vec { cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time), cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol), cmd!("insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), "{url? | slide?: index, tooltip?, ids?}", has_text_or_shapes, hyperlink), + cmd!("chart.type", "Change Chart Type", ["Chart Design", "Type"], None, "{type, id?}", has_selection, chart_type_cmd), + cmd!( + "chart.data", + "Edit Data", + ["Chart Design", "Data"], + None, + "{categories?: [..], series?: [{name, values}], id?}", + has_selection, + chart_data + ), + cmd!( + "chart.options", + "Chart Elements", + ["Chart Design", "Chart Layouts"], + None, + "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, id?}", + has_selection, + chart_options + ), + cmd!( + "insert.smartArt", + "SmartArt", + ["Insert", "Illustrations"], + None, + "{kind: list|process|cycle|hierarchy|pyramid|matrix, items: [text]}", + has_slide, + smart_art + ), cmd!( "insert.actionButton", "Action Buttons", @@ -539,3 +567,169 @@ fn action_button(s: &mut Session, p: &Value) -> Result { fn _kinds() -> RunKind { RunKind::Text } + +fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result { + edit_shapes(s, p, cmd, |sh| match &mut sh.kind { + ShapeKind::Chart(c) => f(c), + _ => Err(bad(cmd, "not a chart")), + }) +} + +fn chart_type_cmd(s: &mut Session, p: &Value) -> Result { + let k = chart_type(str_param(p, "type").unwrap_or("column")); + with_chart(s, p, "chart.type", |c| { + c.kind = k; + c.vary_colors = matches!(k, ChartType::Pie | ChartType::Doughnut) || c.series.len() == 1 && c.vary_colors; + c.raw = None; + Ok(()) + }) +} + +fn chart_data(s: &mut Session, p: &Value) -> Result { + let cats: Option> = p + .get("categories") + .and_then(Value::as_array) + .map(|a| a.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect()); + let series: Option> = p.get("series").and_then(Value::as_array).map(|a| { + a.iter() + .map(|sv| slidecraft_model::chart::Series { + name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), + values: sv.get("values").and_then(Value::as_array).map(|v| v.iter().map(Value::as_f64).collect()).unwrap_or_default(), + ..Default::default() + }) + .collect() + }); + with_chart(s, p, "chart.data", |c| { + if let Some(cs) = &cats { + c.categories = cs.clone(); + } + if let Some(se) = &series { + // Keep series formatting by position. + let old = std::mem::take(&mut c.series); + c.series = se + .iter() + .enumerate() + .map(|(i, n)| { + let mut x = old.get(i).cloned().unwrap_or_default(); + x.name = n.name.clone(); + x.values = n.values.clone(); + x + }) + .collect(); + } + c.raw = None; + Ok(()) + }) +} + +fn chart_options(s: &mut Session, p: &Value) -> Result { + with_chart(s, p, "chart.options", |c| { + if let Some(t) = p.get("title") { + c.title = t.as_str().map(String::from); + } + if let Some(l) = p.get("legend") { + c.legend = l.as_str().map(String::from); + } + if let Some(v) = bool_param(p, "dataLabels") { + c.data_labels = v; + } + if let Some(v) = bool_param(p, "gridlines") { + c.gridlines = v; + } + if let Some(v) = str_param(p, "palette") { + c.palette = v.to_string(); + } + c.raw = None; + Ok(()) + }) +} + +/// SmartArt-style graphics built from shapes (grouped), with our own layouts. +fn smart_art(s: &mut Session, p: &Value) -> Result { + let kind = str_param(p, "kind").unwrap_or("process").to_string(); + let items: Vec = + p.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default(); + let items = if items.is_empty() { vec!["Text".to_string(); 3] } else { items }; + let n = items.len().clamp(1, 12); + let size = s.doc()?.doc.slide_size; + let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); + let slots = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6]; + let mut shapes: Vec<(String, Xfrm, usize)> = vec![]; + match kind.as_str() { + "cycle" => { + let r = area.h.min(area.w) * 0.38; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let d = r * 0.62; + for i in 0..n { + let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); + shapes.push(("ellipse".into(), Xfrm::new(cx + r * a.cos() - d / 2.0, cy + r * a.sin() - d / 2.0, d, d), i)); + } + } + "hierarchy" => { + let w = area.w / (n.max(2) - 1) as f64 * 0.8; + shapes.push(("roundRect".into(), Xfrm::new(area.x + area.w / 2.0 - w / 2.0, area.y, w, area.h * 0.3), 0)); + for i in 1..n { + let x = area.x + (i - 1) as f64 * area.w / (n - 1).max(1) as f64; + shapes.push(("roundRect".into(), Xfrm::new(x + w * 0.1, area.y + area.h * 0.55, w, area.h * 0.3), i)); + } + } + "pyramid" => { + let h = area.h / n as f64; + for i in 0..n { + let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64; + shapes.push(("trapezoid".into(), Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i)); + } + } + "matrix" => { + let (w, h) = (area.w / 2.0, area.h / 2.0); + for i in 0..n.min(4) { + shapes.push(( + "roundRect".into(), + Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0), + i, + )); + } + } + "list" => { + let h = area.h / n as f64; + for i in 0..n { + shapes.push(("roundRect".into(), Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i)); + } + } + _ => { + let gap = 24.0; + let w = (area.w - gap * (n as f64 - 1.0)) / n as f64; + for i in 0..n { + shapes.push(( + if i + 1 < n { "homePlate".into() } else { "roundRect".into() }, + Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5), + i, + )); + } + } + } + let mut ids = vec![]; + for (preset, x, i) in shapes { + let mut sh = Shape { + xfrm: Some(x), + geom: Geom::preset(&preset), + style: Some(ShapeStyle::accent(slots.get(i % 6).copied().unwrap_or(SchemeSlot::Accent1))), + ..Default::default() + }; + let mut body = TextBody::from_text(items.get(i).map(String::as_str).unwrap_or("")); + body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }); + for para in &mut body.paragraphs { + para.props.align = Some(slidecraft_model::text::Align::Center); + for r in &mut para.runs { + r.props.size = Some(20.0); + } + } + sh.text = Some(body); + ids.push(add_shape(s, sh, false)?.0); + } + let g = s.execute("arrange.group", &json!({"ids": ids}))?; + if let Some(gid) = g.get("id").and_then(Value::as_u64) { + let _ = s.execute("shape.rename", &json!({"id": gid, "name": format!("SmartArt ({kind})")})); + } + Ok(g) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3d87422..a086420 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -242,7 +242,7 @@ pub(crate) fn shape_of(s: &Session, id: ShapeId) -> Result Xfrm { +pub fn xfrm_of(doc: &slidecraft_model::Presentation, sel: &crate::Selection, shape: &slidecraft_model::Shape) -> Xfrm { if let Some(x) = shape.xfrm { return x; } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index eca310b..f5c8daa 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -210,6 +210,11 @@ pub fn snap(v: f64, step: f64) -> f64 { if step > 0.0 && step.is_finite() { (v / step).round() * step } else { v } } +/// Flatten a path to move/line/close elements (tolerance 0.2). +pub fn preset_flatten(path: &BezPath, f: &mut dyn FnMut(PathEl)) { + kurbo::flatten(path.iter(), 0.2, f); +} + #[cfg(test)] mod tests { use super::*; @@ -264,3 +269,4 @@ mod tests { assert!((dist_to_segment(Point::new(0.0, 3.0), Point::new(0.0, 0.0), Point::new(0.0, 0.0)) - 3.0).abs() < 1e-9); } } + diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5c5be46..43a2aa2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -184,15 +184,54 @@ thread_local! { static RENDERER: std::cell::RefCell = std::cell::RefCell::new(Renderer::default()); } -/// Render slide `index` at `opts.scale` pixels per point. +/// Does anything on this slide (or its layout/master graphics) use blur-based effects? +pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool { + let Some(rctx) = Ctx::for_slide(pres, slide) else { return false }; + let mut any = false; + let mut check = |shapes: &[Shape]| { + slidecraft_model::walk(shapes, &mut |s, _| { + if any { + return; + } + let (e, _) = resolve::effects(&rctx, s); + if e.is_some_and(|e| !e.is_empty()) { + any = true; + } + }); + }; + check(&slide.shapes); + if let Some(l) = rctx.layout { + check(&l.shapes); + } + check(&rctx.master.shapes); + any +} + +/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a +/// blank image (logged), never a crash. pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image { let Some(slide) = pres.slides.get(index) else { return Image::default() }; - RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts)) + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts)))) { + Ok(img) => img, + Err(_) => { + log::error!("rendering slide {index} panicked; showing a blank slide"); + RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); + let (w, h) = output_size(pres, opts); + Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] } + } + } } /// Render a layout (Slide Master view thumbnail and canvas). pub fn render_layout(pres: &Presentation, master: usize, layout: Option, opts: &RenderOpts) -> Image { - RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)) + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) { + Ok(img) => img, + Err(_) => { + log::error!("rendering a layout panicked; showing a blank image"); + RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); + Image::default() + } + } } fn output_size(pres: &Presentation, opts: &RenderOpts) -> (u16, u16) { @@ -318,7 +357,10 @@ impl Renderer { let mut ctx = RenderContext::new_with( w, h, - vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") { 0 } else { opts.threads }, ..Default::default() }, + vello_cpu::RenderSettings { + num_threads: if cfg!(target_arch = "wasm32") || needs_filters(pres, slide) { 0 } else { opts.threads }, + ..Default::default() + }, ); if let Some(c) = opts.clear { ctx.set_paint(color(c, 1.0)); @@ -888,10 +930,9 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &slidecraft_text::TextLayout, tr: ctx.fill_rect(&d.rect); } for run in &l.runs { - if (run.alpha <= 0.0 || run.color.a == 0) - && run.outline.is_none() { - continue; - } + if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { + continue; + } let k = run.size / run.face.upem.max(1.0); ctx.set_paint(color(run.color, run.alpha)); for (gid, x, y) in &run.glyphs { @@ -945,3 +986,28 @@ pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) #[cfg(test)] mod tests; + +#[cfg(test)] +mod mt_tests { + use super::*; + + /// Regression: multithreaded rendering of a slide with shadows panicked inside vello_cpu. + #[test] + fn effects_render_with_threads_requested() { + let mut s = slidecraft_model::Presentation::default(); + let sl = std::sync::Arc::make_mut(&mut s.slides[0]); + let mut sh = slidecraft_model::Shape { + id: ShapeId(900), + xfrm: Some(slidecraft_geom::Xfrm::new(10.0, 10.0, 100.0, 100.0)), + style: Some(slidecraft_model::ShapeStyle::accent(slidecraft_color::SchemeSlot::Accent1)), + ..Default::default() + }; + sh.effects = Some(slidecraft_model::Effects { soft_edge: Some(5.0), ..Default::default() }); + sl.shapes.push(sh); + assert!(needs_filters(&s, &s.slides[0])); + let img = RENDERER.with(|r| r.borrow_mut().slide(&s, &s.slides[0], 0, &RenderOpts { scale: 0.5, threads: 4, ..Default::default() })); + assert_eq!(img.width, 480); + // The shape itself is drawn (not a blank fallback). + assert_ne!(img.pixel(30, 30), [255, 255, 255, 255]); + } +} diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index 0bf79c9..6a5c188 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -73,11 +73,7 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { border: Some((white, 1.0)), }, "medium4" => CellLook { - fill: Some(if header || band || band_c { - tint(40000) - } else { - tint(20000) - }), + fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }), text: None, bold: header || total || first_col || last_col, border: Some((a, 0.75)), diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml new file mode 100644 index 0000000..8684384 --- /dev/null +++ b/crates/ui-egui/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "slidecraft-ui-egui" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft egui frontend (PowerPoint-style UI); swappable — nothing below depends on it" + +[lints] +workspace = true + +[dependencies] +slidecraft-geom = { workspace = true } +slidecraft-color = { workspace = true } +slidecraft-model = { workspace = true } +slidecraft-fonts = { workspace = true } +slidecraft-text = { workspace = true } +slidecraft-render = { workspace = true } +slidecraft-anim = { workspace = true } +slidecraft-engine = { workspace = true } +egui = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } + +[dev-dependencies] +egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } diff --git a/crates/ui-egui/examples/icon_gallery.rs b/crates/ui-egui/examples/icon_gallery.rs new file mode 100644 index 0000000..aafc5f2 --- /dev/null +++ b/crates/ui-egui/examples/icon_gallery.rs @@ -0,0 +1,65 @@ +//! Renders every SlideCraft icon into a labelled PNG grid, on a light and a dark background. +//! +//! ```sh +//! cargo run -p slidecraft-ui-egui --example icon_gallery -- /tmp/gallery.png +//! ``` +//! +//! Each cell shows the icon at 32 pt, at 16 pt, and disabled at 16 pt, with its name underneath. +//! An optional second argument filters icons by name substring. + +use egui::{Align2, Color32, FontId, Rect, pos2, vec2}; +use slidecraft_ui_egui::icons::{self, Icon}; + +const COLS: usize = 12; +const CELL_W: f32 = 110.0; +const CELL_H: f32 = 64.0; +const PAD: f32 = 12.0; + +struct Theme { + bg: Color32, + ink: Color32, + label: Color32, +} + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let out = args.next().unwrap_or_else(|| "icon_gallery.png".to_owned()); + let filter = args.next(); + let list: Vec = Icon::ALL.iter().copied().filter(|i| filter.as_deref().is_none_or(|f| i.name().contains(f))).collect(); + + let rows = list.len().div_ceil(COLS); + let panel_h = rows as f32 * CELL_H + PAD * 2.0; + let width = COLS as f32 * CELL_W + PAD * 2.0; + let themes = [ + Theme { bg: Color32::from_rgb(0xff, 0xff, 0xff), ink: Color32::from_rgb(0x3a, 0x3a, 0x3a), label: Color32::from_rgb(0x70, 0x70, 0x70) }, + Theme { bg: Color32::from_rgb(0x2b, 0x2b, 0x2b), ink: Color32::from_rgb(0xe8, 0xe8, 0xe8), label: Color32::from_rgb(0xa8, 0xa8, 0xa8) }, + ]; + + let mut harness = + egui_kittest::Harness::builder().with_size(vec2(width, panel_h * themes.len() as f32)).with_pixels_per_point(2.0).wgpu().build_ui(|ui| { + let painter = ui.painter(); + for (ti, theme) in themes.iter().enumerate() { + let top = ti as f32 * panel_h; + painter.rect_filled(Rect::from_min_size(pos2(0.0, top), vec2(width, panel_h)), 0.0, theme.bg); + for (n, &icon) in list.iter().enumerate() { + let x = PAD + (n % COLS) as f32 * CELL_W; + let y = top + PAD + (n / COLS) as f32 * CELL_H; + let big = Rect::from_min_size(pos2(x + 14.0, y + 4.0), vec2(32.0, 32.0)); + icons::paint(painter, big, icon, theme.ink, false); + let small = Rect::from_min_size(pos2(x + 56.0, y + 12.0), vec2(16.0, 16.0)); + icons::paint(painter, small, icon, theme.ink, false); + let dis = Rect::from_min_size(pos2(x + 80.0, y + 12.0), vec2(16.0, 16.0)); + icons::paint(painter, dis, icon, theme.ink, true); + painter.text(pos2(x + CELL_W * 0.5, y + 46.0), Align2::CENTER_CENTER, icon.name(), FontId::proportional(9.5), theme.label); + } + } + }); + harness.run(); + let image = harness.render()?; + if let Some(dir) = std::path::Path::new(&out).parent() { + std::fs::create_dir_all(dir)?; + } + image.save(&out)?; + println!("wrote {} icons to {out}", list.len()); + Ok(()) +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs new file mode 100644 index 0000000..1a7ad6a --- /dev/null +++ b/crates/ui-egui/src/canvas.rs @@ -0,0 +1,491 @@ +//! The slide canvas: the rendered slide, zoom and fit, pointer input routed to the engine's tools, +//! and the overlays drawn on top (selection box and handles, smart guides, marquee, drawing +//! previews, the text caret and text selection). + +use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; +use slidecraft_engine::tools::{Hit, box_handles}; +use slidecraft_engine::{Mods, PointerEvent, PointerKind, Target, ToolKind}; +use slidecraft_geom::{Point, Xfrm}; + +use crate::SlideApp; +use crate::theme::{self, Tokens}; + +/// Slide-to-screen mapping. +#[derive(Clone, Copy, Debug)] +pub struct Xf { + pub origin: Pos2, + pub scale: f32, +} + +impl Xf { + pub fn to_screen(&self, p: Point) -> Pos2 { + pos2(self.origin.x + p.x as f32 * self.scale, self.origin.y + p.y as f32 * self.scale) + } + pub fn to_slide(&self, p: Pos2) -> Point { + Point::new(((p.x - self.origin.x) / self.scale) as f64, ((p.y - self.origin.y) / self.scale) as f64) + } +} + +fn mods(ui: &Ui) -> Mods { + ui.input(|i| Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command || i.modifiers.ctrl }) +} + +/// The slide's rect on screen for the current zoom. +pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { + let Some(d) = app.session.active() else { return (avail, 1.0) }; + let (sw, sh) = (d.doc.slide_size.width as f32, d.doc.slide_size.height as f32); + let margin = 28.0; + let fit = ((avail.width() - margin * 2.0) / sw).min((avail.height() - margin * 2.0) / sh).max(0.05); + let scale = app.ui.zoom.unwrap_or(fit); + let size = vec2(sw * scale, sh * scale); + let mut r = Rect::from_center_size(avail.center(), size); + if size.x > avail.width() { + r = Rect::from_min_size(pos2(avail.min.x + margin, r.min.y), size); + } + if size.y > avail.height() { + r = Rect::from_min_size(pos2(r.min.x, avail.min.y + margin), size); + } + (r, scale) +} + +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let avail = ui.available_rect_before_wrap(); + app.canvas_rect = Some(avail); + let Some(st) = app.session.active() else { return }; + if st.doc.slides.is_empty() && st.selection.target == Target::Slides { + let resp = ui.allocate_rect(avail, Sense::click()); + ui.painter().text(avail.center(), Align2::CENTER_CENTER, "Click to add first slide", theme::font(22.0), t.text_dim); + if resp.clicked() { + let _ = app.run("slide.new", json!({"layout": "title"})); + } + return; + } + let (content_rect, scale) = layout(app, avail); + // Scroll when zoomed beyond the window. + let scroll_id = ui.id().with("canvas_scroll"); + let mut offset: egui::Vec2 = ui.data_mut(|d| d.get_temp(scroll_id).unwrap_or_default()); + let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0)); + let resp = ui.allocate_rect(avail, Sense::click_and_drag()); + let (wheel, zoom_delta, pinch_pos) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos())); + if resp.hovered() { + if (zoom_delta - 1.0).abs() > 1e-3 { + let cur = app.ui.zoom.unwrap_or(scale); + app.ui.zoom = Some((cur * zoom_delta).clamp(0.1, 4.0)); + let _ = pinch_pos; + } else if overflow != egui::Vec2::ZERO { + offset -= wheel; + } else if wheel.y.abs() > 18.0 && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { + // Wheel over a fitted slide pages through slides (like PowerPoint). + let id = if wheel.y < 0.0 { "slide.next" } else { "slide.previous" }; + let gate = ui.id().with("wheel_gate"); + let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(0.0)); + let now = ui.input(|i| i.time); + if now - last > 0.35 { + ui.data_mut(|d| d.insert_temp(gate, now)); + let _ = app.run(id, json!({})); + } + } + } + offset.x = offset.x.clamp(0.0, overflow.x); + offset.y = offset.y.clamp(0.0, overflow.y); + ui.data_mut(|d| d.insert_temp(scroll_id, offset)); + let slide_rect = content_rect.translate(-offset); + app.slide_rect = Some(slide_rect); + app.canvas_scale = scale; + let xf = Xf { origin: slide_rect.min, scale }; + let painter = ui.painter_at(avail); + // Shadow + slide. + painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow); + let ppp = ui.ctx().pixels_per_point(); + let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32); + let st = app.session.active(); + let tex = match st.map(|d| (d.selection.target, d)) { + Some((Target::Slides, d)) => { + let idx = d.selection.slide; + let doc = d.doc.clone(); + app.textures.slide(ui.ctx(), &doc, idx, px, true, 0, app.ui.grayscale) + } + Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px), + Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px), + None => None, + }; + if let Some(tex) = tex { + painter.image(tex.id(), slide_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + app.perf.render_ms = app.textures.last_render_ms; + if app.ui.gridlines { + let step = 72.0 / 2.0 * scale; + let mut x = slide_rect.min.x + step; + while x < slide_rect.max.x { + painter.line_segment([pos2(x, slide_rect.min.y), pos2(x, slide_rect.max.y)], Stroke::new(0.5, Color32::from_black_alpha(40))); + x += step; + } + let mut y = slide_rect.min.y + step; + while y < slide_rect.max.y { + painter.line_segment([pos2(slide_rect.min.x, y), pos2(slide_rect.max.x, y)], Stroke::new(0.5, Color32::from_black_alpha(40))); + y += step; + } + } + if app.ui.guides { + let c = slide_rect.center(); + painter.line_segment( + [pos2(c.x, slide_rect.min.y), pos2(c.x, slide_rect.max.y)], + Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), + ); + painter.line_segment( + [pos2(slide_rect.min.x, c.y), pos2(slide_rect.max.x, c.y)], + Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)), + ); + } + if app.ui.ruler { + ruler(&painter, avail, slide_rect, scale, &t); + } + pointer(app, ui, &resp, xf); + overlays(app, ui, &painter, xf, &t); + context_menu(app, &resp); +} + +fn master_tex( + ctx: &egui::Context, + doc: &slidecraft_model::Presentation, + master: usize, + layout: Option, + px: (u32, u32), +) -> Option { + let img = slidecraft_render::render_layout( + doc, + master, + layout, + &slidecraft_render::RenderOpts { scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, size: Some(px), ..Default::default() }, + ); + Some(ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR)) +} + +fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { + let top = Rect::from_min_max(avail.min, pos2(avail.max.x, avail.min.y + 16.0)); + let left = Rect::from_min_max(avail.min, pos2(avail.min.x + 16.0, avail.max.y)); + p.rect_filled(top, CornerRadius::ZERO, t.card); + p.rect_filled(left, CornerRadius::ZERO, t.card); + let inch = 72.0 * scale; + let mut k = 0; + loop { + let x = slide.min.x + k as f32 * inch / 4.0; + if x > slide.max.x { + break; + } + let h = if k % 4 == 0 { + 8.0 + } else if k % 2 == 0 { + 5.0 + } else { + 3.0 + }; + p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint)); + if k % 4 == 0 { + p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / 4), theme::font(8.0), t.text_dim); + } + k += 1; + } + let mut k = 0; + loop { + let y = slide.min.y + k as f32 * inch / 4.0; + if y > slide.max.y { + break; + } + let w = if k % 4 == 0 { + 8.0 + } else if k % 2 == 0 { + 5.0 + } else { + 3.0 + }; + p.line_segment([pos2(left.max.x - w, y), pos2(left.max.x, y)], Stroke::new(1.0, t.text_faint)); + k += 1; + } +} + +fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { + let tol = (4.0 / xf.scale) as f64; + let m = mods(ui); + let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + let p = xf.to_slide(pos); + let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol }; + let primary = ui.input(|i| i.pointer.primary_pressed()); + if resp.hovered() && !app.session.tool.dragging() { + let _ = app.session.pointer(ev(PointerKind::Move)); + } + let r = if resp.triple_clicked() { + app.session.pointer(ev(PointerKind::TripleClick)) + } else if resp.double_clicked() { + app.session.pointer(ev(PointerKind::DoubleClick)) + } else if primary && resp.hovered() && !resp.secondary_clicked() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) { + app.session.pointer(ev(PointerKind::Down)) + } else if resp.dragged_by(egui::PointerButton::Primary) || (app.session.tool.dragging() && ui.input(|i| i.pointer.primary_down())) { + app.session.pointer(ev(PointerKind::Drag)) + } else if app.session.tool.dragging() && ui.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) { + app.session.pointer(ev(PointerKind::Up)) + } else { + Ok(serde_json::Value::Null) + }; + if let Err(e) = r { + app.set_status(e.to_string()); + } + // Cursor. + let cursor = match (&app.session.tool.kind, &app.session.tool.hover) { + (ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair, + (ToolKind::TextBox, _) => egui::CursorIcon::Text, + (ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair, + (_, Some(Hit::Resize { handle, .. })) => match handle { + 0 | 4 => egui::CursorIcon::ResizeNwSe, + 2 | 6 => egui::CursorIcon::ResizeNeSw, + 1 | 5 => egui::CursorIcon::ResizeVertical, + _ => egui::CursorIcon::ResizeHorizontal, + }, + (_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias, + (_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand, + (_, Some(Hit::Shape { text: true, .. })) => { + if app.session.active().is_some_and(|d| d.selection.text.is_some()) { + egui::CursorIcon::Text + } else { + egui::CursorIcon::Move + } + } + (_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move, + _ => egui::CursorIcon::Default, + }; + if resp.hovered() { + ui.ctx().set_cursor_icon(cursor); + } + if app.session.tool.dragging() { + ui.ctx().request_repaint(); + } +} + +fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) { + for i in 0..4 { + p.line_segment([pts[i], pts[(i + 1) % 4]], stroke); + } +} + +fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { + let Some(st) = app.session.active() else { return }; + let sel_stroke = Stroke::new(1.0, t.selection); + let editing = st.selection.text.clone(); + for id in &st.selection.shapes { + let Some(sh) = st.shape(*id) else { continue }; + let x = slidecraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); + let a = x.affine(); + let corners = [Point::new(0.0, 0.0), Point::new(x.w, 0.0), Point::new(x.w, x.h), Point::new(0.0, x.h)].map(|q| xf.to_screen(a * q)); + if sh.is_line() { + let (p0, p1) = (xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h))); + for q in [p0, p1] { + painter.circle(q, 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke)); + } + continue; + } + let editing_this = editing.as_ref().is_some_and(|e| e.shape == *id && !e.notes); + if editing_this { + // Dashed box while editing text. + for i in 0..4 { + let (s, e) = (corners[i], corners[(i + 1) % 4]); + painter.add(egui::Shape::dashed_line(&[s, e], Stroke::new(1.0, t.selection), 4.0, 3.0)); + } + } else { + poly(painter, corners, sel_stroke); + } + if st.selection.shapes.len() == 1 || !editing_this { + let (hs, rot) = box_handles(&x); + let rot_s = xf.to_screen(rot); + if !editing_this && sh.ph.as_ref().is_none_or(|_| true) { + let top_mid = xf.to_screen(hs[1]); + painter.line_segment([top_mid, rot_s], sel_stroke); + rotate_handle(painter, rot_s, t); + } + for h in hs { + painter.circle(xf.to_screen(h), 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke)); + } + let geo = slidecraft_render::shape_geometry(sh, x.w, x.h); + for hd in &geo.handles { + let q = xf.to_screen(a * hd.pos); + painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00))); + } + } + } + // Caret / text selection. + if let Some(ts) = &editing + && !ts.notes + && ts.cell.is_none() + && let Some(sh) = st.shape(ts.shape) + && let Some(l) = slidecraft_engine::cmd::text::layout_for(st, ts) + { + let x = slidecraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); + let a = x.affine(); + let rot = if l.rotation != 0.0 { + let c = l.inner.center().to_vec2(); + slidecraft_geom::Affine::translate(c) * slidecraft_geom::Affine::rotate(l.rotation.to_radians()) * slidecraft_geom::Affine::translate(-c) + } else { + slidecraft_geom::Affine::IDENTITY + }; + let m = a * rot; + let (s0, s1) = ts.ordered(); + for r in l.selection_rects(slidecraft_text::Pos { para: s0.0, ch: s0.1 }, slidecraft_text::Pos { para: s1.0, ch: s1.1 }) { + let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q)); + painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE)); + } + if !ts.is_range() { + let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(300)); + if blink && let Some((cx, top, bot)) = l.caret(slidecraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }) { + let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot))); + painter.line_segment([p0, p1], Stroke::new(1.5, t.caret)); + } + } + } + // Table cell editing: outline the cell. + if let Some(ts) = &editing + && let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape)) + && let slidecraft_model::ShapeKind::Table(tb) = &sh.kind + { + let x = slidecraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh); + let y0: f64 = tb.rows.iter().take(r).map(|rr| rr.height).sum(); + let x0: f64 = tb.cols.iter().take(c).sum(); + let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), tb.rows.get(r).map(|rr| rr.height).unwrap_or(0.0)); + let a = x.affine(); + let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q)); + poly(painter, pts, Stroke::new(2.0, t.accent)); + } + // Smart guides, marquee, previews. + for g in &app.session.tool.guides { + let (a, b) = + if g.vertical { (Point::new(g.pos, g.from), Point::new(g.pos, g.to)) } else { (Point::new(g.from, g.pos), Point::new(g.to, g.pos)) }; + painter.add(egui::Shape::dashed_line(&[xf.to_screen(a), xf.to_screen(b)], Stroke::new(1.0, t.guide), 5.0, 3.0)); + } + if let Some(m) = app.session.tool.marquee { + let r = Rect::from_two_pos(xf.to_screen(Point::new(m.x0, m.y0)), xf.to_screen(Point::new(m.x1, m.y1))); + painter.rect( + r, + CornerRadius::ZERO, + Color32::from_rgba_unmultiplied(80, 130, 230, 30), + Stroke::new(1.0, Color32::from_rgb(80, 130, 230)), + egui::StrokeKind::Inside, + ); + } + if let Some(pv) = app.session.tool.preview { + draw_preview(app, painter, xf, pv, t); + } + if app.session.tool.ink_preview.len() > 1 + && let ToolKind::Ink { color, width, mode } = &app.session.tool.kind + { + let pts: Vec = app.session.tool.ink_preview.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect(); + let c = theme::to_color32(*color); + let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c }; + painter.add(egui::Shape::line(pts, Stroke::new((*width as f32 * xf.scale).max(1.0), c))); + } +} + +fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) { + p.circle_filled(c, 6.0, t.handle_fill); + p.add(egui::Shape::Path(egui::epaint::PathShape { + points: (0..=20) + .map(|i| { + let a = -0.4 + i as f32 / 20.0 * 5.2; + pos2(c.x + 4.0 * a.cos(), c.y + 4.0 * a.sin()) + }) + .collect(), + closed: false, + fill: Color32::TRANSPARENT, + stroke: Stroke::new(1.3, t.handle_stroke).into(), + })); +} + +fn draw_preview(app: &SlideApp, p: &egui::Painter, xf: Xf, x: Xfrm, t: &Tokens) { + let preset = match &app.session.tool.kind { + ToolKind::Shape { preset } => preset.clone(), + _ => "rect".into(), + }; + if slidecraft_geom::preset::is_line_like(&preset) { + let a = x.affine(); + p.line_segment([xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h))], Stroke::new(1.5, t.accent)); + return; + } + let r = Rect::from_two_pos(xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h))); + if r.width() < 1.0 || r.height() < 1.0 { + return; + } + crate::ribbon::paint_preset(p, r, &preset, t.accent.gamma_multiply(0.25), t.accent); +} + +fn context_menu(app: &mut SlideApp, resp: &egui::Response) { + resp.context_menu(|ui| { + let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); + for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] { + if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + ui.separator(); + if has_sel { + if ui.button("Edit Text").clicked() { + let _ = app.run("text.edit", json!({})); + ui.close(); + } + ui.menu_button("Bring to Front", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); + }); + ui.menu_button("Send to Back", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); + }); + ui.menu_button("Group", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); + }); + if ui.button("Link…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); + ui.close(); + } + if ui.button("Edit Alt Text…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("altText")); + ui.close(); + } + if ui.button("Set as Default Shape").clicked() { + let _ = app.run("shape.setDefault", json!({})); + ui.close(); + } + if ui.button("Format Shape…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format"})); + ui.close(); + } + } else { + ui.menu_button("Layout", |ui| { + let layouts: Vec<(String, u32)> = app + .session + .active() + .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect())) + .unwrap_or_default(); + for (n, id) in layouts { + if ui.button(n).clicked() { + let _ = app.run("slide.layout", json!({"layout": id})); + ui.close(); + } + } + }); + if ui.button("Reset Slide").clicked() { + let _ = app.run("slide.reset", json!({})); + ui.close(); + } + if ui.button("Format Background…").clicked() { + let _ = app.run("view.pane", json!({"pane": "background"})); + ui.close(); + } + if ui.button("New Comment").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("comment")); + ui.close(); + } + } + }); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs new file mode 100644 index 0000000..25e6a47 --- /dev/null +++ b/crates/ui-egui/src/control.rs @@ -0,0 +1,347 @@ +//! Programmatic control of the running app (agents, tests, MCP `--connect`). +//! +//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`): +//! - `engine.execute {command, params}` / `ui.menu.invoke {command, params}`: run any command (engine or UI) +//! - `engine.commands`: engine + UI commands with enablement; `document.inspect`; `ui.inspect` +//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y}], mods}`: drive the active tool in +//! slide points (same path as the mouse) +//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: keyboard through the engine +//! - `ui.tool.select {tool, preset?}` +//! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}` / `ui.drag {x, y, toX, toY, steps?}`: real egui pointer +//! input in window points (reaches every widget) +//! - `ui.set {tab?, pane?, view?, zoom?, dark?, notes?, ribbonCollapsed?}`; `ui.dialog.close` +//! - `ui.screenshot {path?}`: the whole window as PNG; `ui.render {slide?, scale?, path?}`: one slide (headless) +//! - `ui.resize {width, height}`, `ui.focus` +//! - `show.start {from?}`, `show.key {key}`, `show.end` +//! - `app.open {path}` / `app.save {path?}` / `app.export {path, …}` / `app.quit` + +use std::sync::mpsc::Sender; + +use serde_json::{Value, json}; +use slidecraft_engine::{Mods, PointerEvent}; + +use crate::SlideApp; + +pub type ControlResponse = Value; + +pub struct ControlRequest { + pub method: String, + pub params: Value, + pub reply: Sender, +} + +impl ControlRequest { + pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { + let (tx, rx) = std::sync::mpsc::channel(); + (Self { method: method.into(), params, reply: tx }, rx) + } +} + +pub enum Outcome { + Done(Value), + Screenshot { path: Option }, +} + +fn ok(v: Value) -> Outcome { + Outcome::Done(json!({"ok": true, "result": v})) +} +fn err(e: impl std::fmt::Display) -> Outcome { + Outcome::Done(json!({"ok": false, "error": e.to_string()})) +} +fn wrap(r: Result) -> Outcome { + match r { + Ok(v) => ok(v), + Err(e) => err(e), + } +} + +pub fn all_commands(app: &SlideApp) -> Value { + let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); + for (id, label, sc, params) in crate::UI_COMMANDS { + v.push(json!({"id": id, "label": label, "shortcut": sc, "params": params, "enabled": true, "ui": true})); + } + Value::Array(v) +} + +pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value { + let r = ctx.content_rect(); + json!({ + "ui": serde_json::to_value(&app.ui).unwrap_or_default(), + "tool": serde_json::to_value(&app.session.tool.kind).unwrap_or_default(), + "window": [r.width(), r.height()], + "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), + "slideRect": app.slide_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), + "canvasScale": app.canvas_scale, + "dialog": app.dialog.as_ref().map(|d| d.id.clone()), + "showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})), + "documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::>(), + "status": app.status.as_ref().map(|s| s.0.clone()), + "perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps}, + }) +} + +fn key_from(name: &str) -> Option { + egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { + "enter" | "return" => Some(egui::Key::Enter), + "esc" | "escape" => Some(egui::Key::Escape), + "delete" => Some(egui::Key::Delete), + "backspace" => Some(egui::Key::Backspace), + "left" => Some(egui::Key::ArrowLeft), + "right" => Some(egui::Key::ArrowRight), + "up" => Some(egui::Key::ArrowUp), + "down" => Some(egui::Key::ArrowDown), + "space" => Some(egui::Key::Space), + "tab" => Some(egui::Key::Tab), + _ => None, + }) +} + +fn f(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_f64).map(|v| v as f32) +} + +pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let p = &req.params; + let s = |k: &str| p.get(k).and_then(Value::as_str); + match req.method.as_str() { + "engine.execute" | "ui.menu.invoke" | "command" => { + let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; + let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + wrap(app.run(id, params)) + } + "engine.commands" => ok(all_commands(app)), + "document.inspect" => wrap(app.run("document.inspect", json!({}))), + "ui.inspect" => ok(inspect(app, ctx)), + "ui.pointer" => { + let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; + let base = p.get("mods").map(mods_from).unwrap_or_default(); + let mut last = Value::Null; + for e in events { + let kind = e.get("kind").and_then(Value::as_str).unwrap_or(""); + let Some(kind) = slidecraft_mcp_kind(kind) else { return err(format!("unknown pointer kind `{kind}`")) }; + let (Some(x), Some(y)) = (e.get("x").and_then(Value::as_f64), e.get("y").and_then(Value::as_f64)) else { + return err("pointer events need x and y"); + }; + let mods = e.get("mods").map(mods_from).unwrap_or(base); + match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64 }) { + Ok(v) => last = v, + Err(e) => return err(e), + } + } + ok(json!({"result": last, "selection": app.session.active().map(|d| serde_json::to_value(&d.selection).unwrap_or_default())})) + } + "ui.key" => { + let Some(k) = s("key") else { return err("missing `key`") }; + if let Some(show) = app.show.as_ref() + && !show.ended + && let Some(key) = key_from(k) + { + app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + return ok(json!({"show": true})); + } + wrap(app.session.key(k, mods_from(p)).map_err(|e| e.to_string())) + } + "ui.text" => { + let Some(t) = s("text") else { return err("missing `text`") }; + wrap(app.session.type_text(t).map_err(|e| e.to_string())) + } + "ui.tool.select" => { + let Some(name) = s("tool") else { return err("missing `tool`") }; + match tool_kind(name, s("preset")) { + Some(k) => { + app.session.set_tool(k); + ok(json!({"tool": name})) + } + None => err(format!("unknown tool `{name}`")), + } + } + "ui.move" | "ui.click" | "ui.drag" => { + let (Some(x), Some(y)) = (f(p, "x"), f(p, "y")) else { return err("missing x/y") }; + let pos = egui::pos2(x, y); + app.synthetic.push(egui::Event::PointerMoved(pos)); + if req.method == "ui.click" { + let button = match s("button") { + Some("right" | "secondary") => egui::PointerButton::Secondary, + _ => egui::PointerButton::Primary, + }; + let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3); + for _ in 0..count { + app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: egui::Modifiers::NONE }); + app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE }); + } + } else if req.method == "ui.drag" { + let (Some(tx), Some(ty)) = (f(p, "toX"), f(p, "toY")) else { return err("missing toX/toY") }; + let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 120); + app.synthetic.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + for k in 1..=steps { + let a = k as f32 / steps as f32; + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * a, y + (ty - y) * a))); + } + app.synthetic.push(egui::Event::PointerButton { + pos: egui::pos2(tx, ty), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + } + ctx.request_repaint(); + ok(json!({"queued": app.synthetic.len()})) + } + "ui.set" => { + if let Some(t) = s("tab") { + app.ui.tab = t.to_string(); + app.ui.ribbon_collapsed = false; + } + if let Some(pv) = p.get("pane") { + app.ui.pane = pv.as_str().map(String::from); + } + if let Some(v) = s("view") { + app.ui.view = match v { + "sorter" => crate::ViewMode::Sorter, + "outline" => crate::ViewMode::Outline, + "notesPage" => crate::ViewMode::NotesPage, + _ => crate::ViewMode::Normal, + }; + } + if let Some(z) = p.get("zoom") { + app.ui.zoom = z.as_f64().map(|v| v as f32); + } + if let Some(d) = p.get("dark").and_then(Value::as_bool) { + let _ = app.run("view.dark", json!({"on": d})); + } + if let Some(n) = p.get("notes").and_then(Value::as_bool) { + app.ui.notes = n; + } + if let Some(c) = p.get("ribbonCollapsed").and_then(Value::as_bool) { + app.ui.ribbon_collapsed = c; + } + if let Some(ft) = s("formatTab") { + app.ui.format_tab = ft.to_string(); + } + ok(serde_json::to_value(&app.ui).unwrap_or_default()) + } + "ui.dialog.close" => { + app.dialog = None; + ok(Value::Null) + } + "ui.dialog.open" => { + let Some(id) = s("id") else { return err("missing `id`") }; + app.dialog = Some(crate::dialogs::Dialog::new(id)); + ok(Value::Null) + } + "ui.screenshot" => Outcome::Screenshot { path: s("path").map(String::from) }, + "ui.render" => { + let Some(d) = app.session.active() else { return err("no presentation open") }; + let i = p.get("slide").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(d.selection.slide); + if i >= d.doc.slides.len() { + return err(format!("no slide {i}")); + } + let (png, w, h) = slidecraft_engine::cmd::file::render_png( + &d.doc, + i, + p.get("scale").and_then(Value::as_f64).unwrap_or(1.0), + p.get("edit").and_then(Value::as_bool).unwrap_or(false), + ); + match s("path") { + Some(path) => match app.services.write.as_mut().map(|w| w(path, &png)) { + Some(Ok(())) => ok(json!({"path": path, "width": w, "height": h})), + Some(Err(e)) => err(e), + None => err("no writer"), + }, + None => ok(json!({"png": slidecraft_engine::cmd::base64_encode(&png), "width": w, "height": h, "slide": i})), + } + } + "ui.resize" => { + let (Some(w), Some(h)) = (f(p, "width"), f(p, "height")) else { return err("missing width/height") }; + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); + ok(Value::Null) + } + "ui.focus" => { + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + ok(Value::Null) + } + "show.start" => { + let from = p.get("from").and_then(Value::as_u64).unwrap_or(0) as usize; + app.start_show(from, p.get("reading").and_then(Value::as_bool).unwrap_or(false)); + ok(Value::Null) + } + "show.end" => { + if app.show.is_some() { + app.synthetic.push(egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + } + ok(Value::Null) + } + "app.open" => { + let Some(path) = s("path") else { return err("missing `path`") }; + wrap(app.open_path(path).map(|_| Value::Null)) + } + "app.save" => wrap(app.run("file.save", p.clone())), + "app.export" => wrap(app.run("file.export", p.clone())), + "app.quit" => { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + ok(Value::Null) + } + other => err(format!("unknown method `{other}`")), + } +} + +fn mods_from(p: &Value) -> Mods { + let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); + Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd") || b("ctrl") } +} + +fn slidecraft_mcp_kind(k: &str) -> Option { + use slidecraft_engine::PointerKind::*; + Some(match k { + "down" => Down, + "drag" => Drag, + "up" => Up, + "move" => Move, + "doubleclick" | "dblclick" => DoubleClick, + "tripleclick" => TripleClick, + _ => return None, + }) +} + +pub fn tool_kind(name: &str, preset: Option<&str>) -> Option { + use slidecraft_engine::ToolKind; + Some(match name { + "select" | "selection" => ToolKind::Select, + "textBox" | "text" => ToolKind::TextBox, + "shape" => ToolKind::Shape { preset: preset.unwrap_or("rect").into() }, + "pen" => ToolKind::Ink { mode: "pen".into(), color: slidecraft_color::Rgba::BLACK, width: 2.0 }, + "highlighter" => ToolKind::Ink { mode: "highlighter".into(), color: slidecraft_color::Rgba::rgb(255, 230, 0), width: 10.0 }, + "eraser" => ToolKind::Ink { mode: "eraser".into(), color: slidecraft_color::Rgba::BLACK, width: 2.0 }, + other if slidecraft_geom::preset::info(other).is_some() => ToolKind::Shape { preset: other.into() }, + _ => return None, + }) +} + +pub fn save_screenshot(app: &mut SlideApp, image: &egui::ColorImage, path: Option<&str>) -> Value { + let [w, h] = image.size; + let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + let img = slidecraft_render::Image { width: w as u32, height: h as u32, pixels: rgba }; + let png = img.to_png(); + let Some(path) = path else { + return json!({"ok": true, "result": {"width": w, "height": h, "png": slidecraft_engine::cmd::base64_encode(&png)}}); + }; + match app.services.write.as_mut() { + Some(wr) => match wr(path, &png) { + Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}), + Err(e) => json!({"ok": false, "error": e}), + }, + None => json!({"ok": false, "error": "no writer configured"}), + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs new file mode 100644 index 0000000..4db2d34 --- /dev/null +++ b/crates/ui-egui/src/dialogs.rs @@ -0,0 +1,782 @@ +//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …), the start +//! screen and the About window. + +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::theme::{self, Tokens}; +use crate::{SlideApp, ribbon}; + +#[derive(Clone, Debug, serde::Serialize)] +pub struct Dialog { + pub id: String, + pub params: Value, + /// Field values while the dialog is open. + #[serde(skip)] + pub fields: std::collections::HashMap, +} + +impl Dialog { + pub fn new(id: &str) -> Self { + Dialog { id: id.into(), params: Value::Null, fields: Default::default() } + } + pub fn modal(&self) -> bool { + true + } + fn get(&mut self, k: &str, default: &str) -> String { + self.fields.entry(k.into()).or_insert_with(|| default.to_string()).clone() + } +} + +fn title(id: &str) -> &'static str { + match id { + "table" => "Insert Table", + "slideSize" => "Slide Size", + "headerFooter" => "Header and Footer", + "hyperlink" => "Insert Hyperlink", + "setupShow" => "Set Up Slide Show", + "zoom" => "Zoom", + "comment" => "New Comment", + "altText" => "Alt Text", + "outline" => "Slides from Outline", + "renameSection" => "Rename Section", + "renameShape" => "Rename", + "renameLayout" => "Rename Layout", + "paragraph" => "Paragraph", + "about" => "About SlideCraft", + "preferences" => "Preferences", + "export" => "Export", + "symbol" => "Symbol", + "equation" => "Equation", + "spelling" => "Spelling", + "accessibility" => "Accessibility Checker", + "language" => "Language", + "customShow" => "Custom Shows", + "chartData" => "Chart Data", + "trim" => "Trim Media", + "smartart" => "Choose a SmartArt Graphic", + _ => "SlideCraft", + } +} + +pub fn show(app: &mut SlideApp, ctx: &egui::Context) { + let Some(mut d) = app.dialog.take() else { return }; + let mut open = true; + let mut close = false; + egui::Window::new(title(&d.id)) + .id(egui::Id::new(("dialog", d.id.clone()))) + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)) + .open(&mut open) + .show(ctx, |ui| { + ui.set_min_width(340.0); + close = body(app, ui, &mut d); + }); + if open && !close && !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.dialog = Some(d); + } +} + +fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) { + let mut ok = false; + let mut cancel = false; + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let t = Tokens::get(ui.ctx()); + if ui.add(egui::Button::new(egui::RichText::new(ok_label).color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))).clicked() + || ui.input(|i| i.key_pressed(egui::Key::Enter)) + { + ok = true; + } + if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { + cancel = true; + } + }); + (ok, cancel) +} + +fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) { + let mut v = d.get(key, default); + ui.horizontal(|ui| { + ui.add_sized(vec2(120.0, 20.0), egui::Label::new(label)); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(180.0)); + }); + d.fields.insert(key.into(), v); +} + +fn check(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: bool) -> bool { + let mut v = d.get(key, if default { "1" } else { "0" }) == "1"; + ui.checkbox(&mut v, label); + d.fields.insert(key.into(), if v { "1".into() } else { "0".into() }); + v +} + +fn num(d: &mut Dialog, key: &str, default: f64) -> f64 { + d.get(key, "").trim().trim_end_matches(['"', '%', ' ']).parse::().ok().filter(|v| v.is_finite()).unwrap_or(default) +} + +/// Returns true when the dialog should close. +fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let run = |app: &mut SlideApp, id: &str, p: Value| { + let _ = app.run(id, p); + }; + match d.id.as_str() { + "table" => { + field(ui, d, "Number of columns:", "cols", "5"); + field(ui, d, "Number of rows:", "rows", "2"); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + let (r, c) = (num(d, "rows", 2.0) as usize, num(d, "cols", 5.0) as usize); + run(app, "insert.table", json!({"rows": r, "cols": c})); + } + ok || cancel + } + "slideSize" => { + let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(slidecraft_model::defaults::WIDE); + ui.label("Slides sized for:"); + let preset = d.get("preset", ""); + egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| { + for (label, w, h) in slidecraft_model::defaults::SLIDE_SIZES { + if ui.selectable_label(preset == *label, *label).clicked() { + d.fields.insert("preset".into(), label.to_string()); + d.fields.insert("w".into(), format!("{:.2}", w / 72.0)); + d.fields.insert("h".into(), format!("{:.2}", h / 72.0)); + } + } + }); + field(ui, d, "Width (in):", "w", &format!("{:.2}", cur.width / 72.0)); + field(ui, d, "Height (in):", "h", &format!("{:.2}", cur.height / 72.0)); + let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let (w, h) = (num(d, "w", cur.width / 72.0) * 72.0, num(d, "h", cur.height / 72.0) * 72.0); + run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }})); + } + ok || cancel + } + "headerFooter" => { + let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default(); + let date = check(ui, d, "Date and time", "date", hf.date); + field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text); + let num_on = check(ui, d, "Slide number", "num", hf.slide_number); + let foot = check(ui, d, "Footer", "footer", hf.footer); + field(ui, d, " Footer text:", "footerText", &hf.footer_text); + let hide = check(ui, d, "Don't show on title slide", "hide", hf.hide_on_title); + ui.add_space(6.0); + let mut apply_all = false; + let mut apply = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + apply_all = ui.button("Apply to All").clicked(); + apply = ui.button("Apply").clicked(); + if ui.button("Cancel").clicked() { + d.fields.insert("cancel".into(), "1".into()); + } + }); + if apply || apply_all { + let dt = d.get("dateText", ""); + let ft = d.get("footerText", ""); + run( + app, + "design.headerFooter", + json!({"date": date, "dateText": dt, "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}), + ); + } + apply || apply_all || d.fields.contains_key("cancel") + } + "hyperlink" => { + let mode = d.get("mode", "url"); + ui.horizontal(|ui| { + if ui.selectable_label(mode == "url", "Web Page or File").clicked() { + d.fields.insert("mode".into(), "url".into()); + } + if ui.selectable_label(mode == "slide", "This Document").clicked() { + d.fields.insert("mode".into(), "slide".into()); + } + if ui.selectable_label(mode == "email", "Email Address").clicked() { + d.fields.insert("mode".into(), "email".into()); + } + }); + match mode.as_str() { + "slide" => { + let titles: Vec = app + .session + .active() + .map(|s| s.doc.slides.iter().enumerate().map(|(i, sl)| format!("{}. {}", i + 1, sl.title())).collect()) + .unwrap_or_default(); + let sel = d.get("slide", "0").parse::().unwrap_or(0); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + for (i, t) in titles.iter().enumerate() { + if ui.selectable_label(sel == i, t).clicked() { + d.fields.insert("slide".into(), i.to_string()); + } + } + }); + } + "email" => field(ui, d, "Email address:", "email", ""), + _ => field(ui, d, "Address:", "url", "https://"), + } + field(ui, d, "ScreenTip:", "tip", ""); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let tip = d.get("tip", ""); + let p = match mode.as_str() { + "slide" => json!({"slide": d.get("slide", "0").parse::().unwrap_or(0), "tooltip": tip}), + "email" => json!({"url": format!("mailto:{}", d.get("email", "")), "tooltip": tip}), + _ => json!({"url": d.get("url", ""), "tooltip": tip}), + }; + run(app, "insert.hyperlink", p); + } + ok || cancel + } + "setupShow" => { + let sh = app.session.active().map(|s| s.doc.show.clone()).unwrap_or_default(); + let ty = d.get("type", &sh.show_type); + ui.label("Show type"); + for (l, v) in [ + ("Presented by a speaker (full screen)", "speaker"), + ("Browsed by an individual (window)", "browsed"), + ("Browsed at a kiosk (full screen)", "kiosk"), + ] { + if ui.radio(ty == v, l).clicked() { + d.fields.insert("type".into(), v.into()); + } + } + let lp = check(ui, d, "Loop continuously until 'Esc'", "loop", sh.loop_until_esc); + let nn = check(ui, d, "Show without narration", "nn", sh.without_narration); + let na = check(ui, d, "Show without animation", "na", sh.without_animation); + let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let ty = d.get("type", "speaker"); + run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut})); + } + ok || cancel + } + "zoom" => { + for pct in [400, 200, 150, 100, 75, 66, 50, 33] { + if ui.radio(false, format!("{pct}%")).clicked() { + let _ = app.run("view.zoom", json!({"percent": pct})); + } + } + if ui.button("Fit").clicked() { + app.ui.zoom = None; + } + let (ok, cancel) = buttons(ui, "OK"); + ok || cancel + } + "comment" => { + let mut v = d.get("text", ""); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Start a conversation").desired_rows(4).desired_width(320.0)); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Post"); + if ok && !v.trim().is_empty() { + run(app, "comment.add", json!({"text": v})); + app.ui.pane = Some("comments".into()); + } + ok || cancel + } + "altText" => { + let cur = app.session.active().and_then(|s| s.selected_shapes().first().map(|x| x.descr.clone())).unwrap_or_default(); + ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); + let mut v = d.get("text", &cur); + ui.add(egui::TextEdit::multiline(&mut v).desired_rows(4).desired_width(340.0)); + d.fields.insert("text".into(), v.clone()); + let deco = check(ui, d, "Mark as decorative", "deco", false); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run(app, "shape.altText", json!({"text": v, "decorative": deco})); + } + ok || cancel + } + "outline" => { + ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets."); + let mut v = d.get("text", ""); + ui.add(egui::TextEdit::multiline(&mut v).desired_rows(10).desired_width(380.0).code_editor()); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + run(app, "slide.fromOutline", json!({"text": v})); + } + ok || cancel + } + "renameSection" | "renameShape" | "renameLayout" => { + let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string(); + field(ui, d, "Name:", "name", &cur); + let (ok, cancel) = buttons(ui, "Rename"); + if ok { + let name = d.get("name", ""); + match d.id.as_str() { + "renameSection" => run(app, "section.rename", json!({"index": d.params.get("index").cloned().unwrap_or_default(), "name": name})), + "renameShape" => run(app, "shape.rename", json!({"id": d.params.get("id").cloned().unwrap_or_default(), "name": name})), + _ => run(app, "master.renameLayout", json!({"name": name})), + } + } + ok || cancel + } + "paragraph" => { + field(ui, d, "Before text (in):", "left", "0"); + field(ui, d, "Special — hanging (in):", "hang", "0"); + field(ui, d, "Spacing before (pt):", "before", "0"); + field(ui, d, "Spacing after (pt):", "after", "0"); + field(ui, d, "Line spacing (lines):", "lines", "1.0"); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run( + app, + "format.paragraph", + json!({"indentLeft": num(d, "left", 0.0) * 72.0, "indentFirst": -num(d, "hang", 0.0) * 72.0, "spaceBefore": num(d, "before", 0.0), "spaceAfter": num(d, "after", 0.0), "lineSpacing": num(d, "lines", 1.0)}), + ); + } + ok || cancel + } + "export" => { + ui.label("Export the presentation as:"); + let fmt = d.get("fmt", "png"); + for (l, f) in [ + ("PNG images (current slide)", "png"), + ("PNG images (all slides)", "pngAll"), + ("JPEG image (current slide)", "jpeg"), + ("PowerPoint Presentation (.pptx)", "pptx"), + ("Outline (.txt)", "outline"), + ] { + if ui.radio(fmt == f, l).clicked() { + d.fields.insert("fmt".into(), f.into()); + } + } + let (ok, cancel) = buttons(ui, "Export"); + if ok { + let name = app.session.active().map(|s| s.title()).unwrap_or_else(|| "Presentation".into()); + let (ext, all) = match fmt.as_str() { + "pngAll" => ("png", true), + "jpeg" => ("jpg", false), + "pptx" => ("pptx", false), + "outline" => ("txt", false), + _ => ("png", false), + }; + let suggested = format!("{name}.{ext}"); + if let Some(pick) = app.services.pick_save.as_mut() { + if let Some(path) = pick(&suggested) { + let r = app.session.execute("file.export", &json!({"path": path, "all": all})); + match r { + Ok(_) => app.set_status(format!("Exported {path}")), + Err(e) => app.set_status(e.to_string()), + } + } + } else if let (Some(dl), Some(s)) = (app.services.download.as_mut(), app.session.active()) { + let bytes = match ext { + "png" | "jpg" => { + let img = slidecraft_render::render_slide( + &s.doc, + s.selection.slide, + &slidecraft_render::RenderOpts { scale: 2.0, ..Default::default() }, + ); + if ext == "png" { img.to_png() } else { img.to_jpeg(92) } + } + other => slidecraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(), + }; + dl(&suggested, &bytes); + } + } + ok || cancel + } + "symbol" => { + ui.label("Click a symbol to insert it at the insertion point."); + let groups: [(&str, &str); 6] = [ + ("Punctuation", "–—‘’“”…•·§¶†‡‰′″‹›«»¡¿"), + ("Currency", "$€£¥¢₹₩₽₺₿"), + ("Math", "±×÷≠≈≤≥∞√∑∏∫∂∆∇∈∉∩∪⊂⊃∀∃¬∧∨⇒⇔°′"), + ("Greek", "αβγδεζηθικλμνξοπρστυφχψωΓΔΘΛΞΠΣΦΨΩ"), + ("Arrows", "←↑→↓↔↕⇐⇑⇒⇓↖↗↘↙⟵⟶"), + ("Shapes", "■□▪▫▲△▶▷▼▽◀◁◆◇○●◎★☆✓✔✗✘♠♣♥♦"), + ]; + for (g, chars) in groups { + ui.label(egui::RichText::new(g).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + for c in chars.chars() { + if ui.add(egui::Button::new(egui::RichText::new(c.to_string()).size(16.0)).min_size(vec2(26.0, 26.0))).clicked() { + if app.session.active().is_some_and(|s| s.selection.text.is_none()) { + let _ = app.run("text.edit", json!({})); + } + let _ = app.run("insert.symbol", json!({"text": c.to_string()})); + } + } + }); + } + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel + } + "equation" => { + ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a)."); + let mut v = d.get("eq", ""); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(340.0).font(theme::font(15.0))); + d.fields.insert("eq".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok && !v.is_empty() { + let pretty = pretty_equation(&v); + let size = app.session.active().map(|s| s.doc.slide_size).unwrap_or(slidecraft_model::defaults::WIDE); + run(app, "insert.textBox", json!({"rect": [size.width / 2.0 - 150.0, size.height / 2.0 - 25.0, 300, 50], "text": pretty})); + run(app, "text.exit", json!({})); + run(app, "format.font", json!({"family": "Liberation Serif"})); + run(app, "format.italic", json!({"on": true})); + run(app, "format.size", json!({"size": 28})); + } + ok || cancel + } + "spelling" => { + let v = app.session.execute("review.spelling", &json!({})).unwrap_or_default(); + let list = v.as_array().cloned().unwrap_or_default(); + if list.is_empty() { + ui.label("The spelling check is complete. No issues found."); + } + for item in &list { + ui.horizontal(|ui| { + let w = item.get("word").and_then(Value::as_str).unwrap_or(""); + ui.label(format!("Slide {}: “{w}”", item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1)); + if ui.small_button("Go to").clicked() { + let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); + } + }); + } + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel + } + "accessibility" => { + let v = app.session.execute("review.accessibility", &json!({})).unwrap_or_default(); + let list = v.as_array().cloned().unwrap_or_default(); + if list.is_empty() { + ui.label("No accessibility issues found. People with disabilities should not have difficulty reading this document."); + } + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for item in &list { + ui.horizontal(|ui| { + ui.label(format!( + "Slide {}: {}{}", + item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1, + item.get("issue").and_then(Value::as_str).unwrap_or(""), + item.get("name").and_then(Value::as_str).map(|n| format!(" — {n}")).unwrap_or_default() + )); + if ui.small_button("Go to").clicked() { + let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); + if let Some(id) = item.get("shape") { + let _ = app.run("edit.select", json!({"ids": [id]})); + } + } + }); + } + }); + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel + } + "language" => { + ui.label("Mark selected text as:"); + for l in [ + "English (United States)", + "English (United Kingdom)", + "Español", + "Français", + "Deutsch", + "Italiano", + "Português", + "日本語", + "中文", + "العربية", + ] { + let _ = ui.selectable_label(l.starts_with("English (United States)"), l); + } + let (ok, cancel) = buttons(ui, "OK"); + ok || cancel + } + "customShow" => { + let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default(); + for s in &shows { + ui.horizontal(|ui| { + ui.label(format!("{} ({} slides)", s.name, s.slides.len())); + if ui.small_button("Delete").clicked() { + let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); + } + }); + } + field(ui, d, "New show name:", "name", "Custom Show 1"); + field(ui, d, "Slides (e.g. 1,3,5):", "slides", "1"); + let (ok, cancel) = buttons(ui, "Create"); + if ok { + let idx: Vec = + d.get("slides", "").split(',').filter_map(|s| s.trim().parse::().ok()).filter(|v| *v > 0).map(|v| v - 1).collect(); + run(app, "show.customShow", json!({"name": d.get("name", "Custom Show"), "slides": idx})); + } + ok || cancel + } + "chartData" => { + let ch = app.session.active().and_then(|s| { + s.selected_shapes() + .into_iter() + .find_map(|x| if let slidecraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None }) + }); + let Some((c, id)) = ch else { + ui.label("Select a chart first."); + let (ok, cancel) = buttons(ui, "Close"); + return ok || cancel; + }; + ui.label("Edit the data: first row = series names, first column = categories."); + let mut grid: Vec> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| { + let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::>()]; + for (i, cat) in c.categories.iter().enumerate() { + let mut row = vec![cat.clone()]; + for s in &c.series { + row.push(s.values.get(i).copied().flatten().map(|v| v.to_string()).unwrap_or_default()); + } + g.push(row); + } + g + }); + egui::Grid::new("chartdata").striped(true).show(ui, |ui| { + for row in grid.iter_mut() { + for cell in row.iter_mut() { + ui.add(egui::TextEdit::singleline(cell).desired_width(70.0)); + } + ui.end_row(); + } + }); + ui.horizontal(|ui| { + if ui.button("+ Row").clicked() { + let n = grid.first().map(|r| r.len()).unwrap_or(2); + grid.push(vec![String::new(); n]); + } + if ui.button("+ Series").clicked() { + for r in grid.iter_mut() { + r.push(String::new()); + } + } + }); + d.params = json!({"grid": grid}); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let names: Vec = grid.first().map(|r| r.iter().skip(1).cloned().collect()).unwrap_or_default(); + let cats: Vec = grid.iter().skip(1).map(|r| r.first().cloned().unwrap_or_default()).collect(); + let series: Vec = names.iter().enumerate().map(|(k, n)| json!({"name": n, "values": grid.iter().skip(1).map(|r| r.get(k + 1).and_then(|v| v.trim().parse::().ok())).collect::>()})).collect(); + run(app, "chart.data", json!({"id": id.0, "categories": cats, "series": series})); + } + ok || cancel + } + "trim" => { + field(ui, d, "Start time (s):", "start", "0"); + field(ui, d, "End trim (s):", "end", "0"); + field(ui, d, "Fade in (s):", "fin", "0"); + field(ui, d, "Fade out (s):", "fout", "0"); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run( + app, + "media.options", + json!({"trimStart": (num(d, "start", 0.0) * 1000.0) as u64, "trimEnd": (num(d, "end", 0.0) * 1000.0) as u64, "fadeIn": (num(d, "fin", 0.0) * 1000.0) as u64, "fadeOut": (num(d, "fout", 0.0) * 1000.0) as u64}), + ); + } + ok || cancel + } + "smartart" => { + ui.label("Pick a graphic; type one item per line."); + let kind = d.get("kind", "process"); + ui.horizontal_wrapped(|ui| { + for (k, l) in [ + ("list", "Basic List"), + ("process", "Basic Process"), + ("cycle", "Basic Cycle"), + ("hierarchy", "Hierarchy"), + ("pyramid", "Pyramid"), + ("matrix", "Matrix"), + ] { + if ui.selectable_label(kind == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + } + }); + let mut v = d.get("text", "Plan\nBuild\nLaunch"); + ui.add(egui::TextEdit::multiline(&mut v).desired_rows(5).desired_width(320.0)); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::>()})); + } + ok || cancel + } + "about" => { + about(app, ui); + let (ok, _) = buttons(ui, "Close"); + ok + } + "preferences" => { + ui.label(egui::RichText::new("General").font(theme::bold(13.0))); + let mut dark = app.ui.brightness == theme::Brightness::Dark; + if ui.checkbox(&mut dark, "Dark appearance").changed() { + let _ = app.run("view.dark", json!({"on": dark})); + } + let mut scale = app.ui.ui_scale; + if ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size")).changed() { + app.ui.ui_scale = scale; + } + ui.label(egui::RichText::new("Editing").font(theme::bold(13.0))); + ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); + ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); + ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes"); + ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); + ui.horizontal(|ui| { + ui.label("Your name (comments):"); + ui.text_edit_singleline(&mut app.session.prefs.author); + }); + let (ok, _) = buttons(ui, "Done"); + ok + } + _ => { + ui.label(format!("“{}” is not available yet.", d.id)); + let (ok, cancel) = buttons(ui, "OK"); + ok || cancel + } + } +} + +fn pretty_equation(s: &str) -> String { + let sup = |c: char| match c { + '0' => '⁰', + '1' => '¹', + '2' => '²', + '3' => '³', + '4' => '⁴', + '5' => '⁵', + '6' => '⁶', + '7' => '⁷', + '8' => '⁸', + '9' => '⁹', + 'n' => 'ⁿ', + 'i' => 'ⁱ', + '+' => '⁺', + '-' => '⁻', + _ => c, + }; + let mut out = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '^' => { + while let Some(&n) = chars.peek() { + if n.is_ascii_alphanumeric() || n == '+' || n == '-' { + out.push(sup(n)); + chars.next(); + if !n.is_ascii_digit() { + break; + } + } else { + break; + } + } + } + '*' => out.push('·'), + _ => out.push(c), + } + } + out.replace("sqrt", "√").replace("<=", "≤").replace(">=", "≥").replace("!=", "≠").replace("pi", "π").replace("+-", "±") +} + +pub fn about(_app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.vertical_centered(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover()); + paint_logo(ui.painter(), r); + ui.label(egui::RichText::new("SlideCraft").font(theme::bold(22.0))); + ui.label(egui::RichText::new(format!("Version {}", env!("CARGO_PKG_VERSION"))).color(t.text_dim)); + ui.add_space(6.0); + ui.label("Presentations and slide shows, rebuilt from scratch in pure Rust."); + ui.label("An open-source, clean-room project. Not affiliated with Microsoft."); + ui.add_space(6.0); + ui.hyperlink_to("Join our community on Discord", slidecraft_engine::links::DISCORD); + ui.hyperlink_to("getartcraft.com/apps/slidecraft", slidecraft_engine::links::APP_PAGE); + ui.hyperlink_to("Source on GitHub", slidecraft_engine::links::GITHUB); + ui.add_space(6.0); + ui.label(egui::RichText::new("MIT OR Apache-2.0 · © 2026 ArtCraft Team and the SlideCraft contributors").size(11.0).color(t.text_faint)); + }); +} + +/// SlideCraft's mark, drawn in code: a slide card with a play triangle in our orange. +pub fn paint_logo(p: &egui::Painter, r: Rect) { + let orange = Color32::from_rgb(0xF2, 0x6B, 0x1D); + let deep = Color32::from_rgb(0xC2, 0x4E, 0x14); + let card = r.shrink(r.width() * 0.08); + p.rect_filled(card.translate(vec2(r.width() * 0.04, r.width() * 0.05)), CornerRadius::same((r.width() * 0.14) as u8), deep); + p.rect_filled(card, CornerRadius::same((r.width() * 0.14) as u8), orange); + let c = card.center(); + let s = card.width() * 0.22; + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x - s * 0.7, c.y - s), pos2(c.x + s, c.y), pos2(c.x - s * 0.7, c.y + s)], + Color32::WHITE, + Stroke::NONE, + )); +} + +/// The start screen (no presentation open): new from themes, open, recent files. +pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::ScrollArea::vertical().show(ui, |ui| { + ui.add_space(24.0); + ui.horizontal(|ui| { + ui.add_space(40.0); + let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover()); + paint_logo(ui.painter(), r); + ui.label(egui::RichText::new("SlideCraft").font(theme::bold(26.0))); + }); + ui.add_space(16.0); + ui.horizontal(|ui| { + ui.add_space(40.0); + ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0))); + }); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + ui.add_space(40.0); + for th in slidecraft_model::theme::builtin_themes() { + let (r, resp) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click()); + let tile = Rect::from_min_size(r.min, vec2(180.0, 101.0)); + ribbon::paint_theme_tile(ui.painter(), tile, &th); + if resp.hovered() { + ui.painter().rect_stroke(tile, CornerRadius::same(2), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); + } + ui.painter().text(pos2(r.min.x, r.max.y - 10.0), Align2::LEFT_CENTER, &th.name, theme::font(12.0), t.text); + if resp.clicked() { + let _ = app.run("file.new", json!({"theme": th.name})); + } + } + }); + ui.add_space(16.0); + ui.horizontal(|ui| { + ui.add_space(40.0); + if ui.button("Open…").clicked() { + let _ = app.run("app.openDialog", json!({})); + } + if ui.button("Open the sample deck").clicked() { + if let Err(e) = slidecraft_engine::sample::open_sample(&mut app.session) { + app.set_status(e.to_string()); + } + } + }); + if !app.ui.recent.is_empty() { + ui.add_space(16.0); + ui.horizontal(|ui| { + ui.add_space(40.0); + ui.label(egui::RichText::new("Recent").font(theme::bold(16.0))); + }); + for p in app.ui.recent.clone() { + ui.horizontal(|ui| { + ui.add_space(40.0); + if ui.link(&p).clicked() + && let Err(e) = app.open_path(&p) + { + app.set_status(e); + } + }); + } + } + ui.add_space(30.0); + ui.horizontal(|ui| { + ui.add_space(40.0); + ui.hyperlink_to("Join the ArtCraft community on Discord", slidecraft_engine::links::DISCORD); + }); + }); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs new file mode 100644 index 0000000..2ac9bca --- /dev/null +++ b/crates/ui-egui/src/icons.rs @@ -0,0 +1,2427 @@ +//! Ribbon and UI icons, drawn in code. +//! +//! Every icon here is an original drawing made from egui painter primitives on a 24×24 design +//! grid (contributor-original, `MIT OR Apache-2.0`). Nothing is traced from or modelled on any +//! vendor's artwork (AGENTS.md §1). +//! +//! Style: line icons in the theme's ink colour with one or two fixed accent colours. When an icon +//! is disabled everything is drawn in ink at ~35% alpha. + +use std::f32::consts::PI; + +use egui::epaint::{Mesh, PathShape, TextShape}; +use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Shape, Stroke, StrokeKind, pos2, vec2}; + +/// Accent blue. +pub const BLUE: Color32 = Color32::from_rgb(0x2E, 0x6F, 0xD8); +/// Accent orange. +pub const ORANGE: Color32 = Color32::from_rgb(0xF2, 0x6B, 0x1D); +/// Accent green. +pub const GREEN: Color32 = Color32::from_rgb(0x2E, 0xA3, 0x6F); +/// Accent purple. +pub const PURPLE: Color32 = Color32::from_rgb(0x8A, 0x5C, 0xD8); +/// Accent red. +pub const RED: Color32 = Color32::from_rgb(0xD8, 0x3F, 0x6B); +/// Accent gold. +pub const GOLD: Color32 = Color32::from_rgb(0xE3, 0xB2, 0x1C); +/// Fixed dark ink used for marks drawn on top of gold fills (readable in both themes). +const DARK: Color32 = Color32::from_rgb(0x2B, 0x2B, 0x2B); + +macro_rules! icons { + ($($(#[$m:meta])* $v:ident => $n:literal,)*) => { + /// Every icon SlideCraft draws. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub enum Icon { + $($(#[$m])* $v,)* + } + + impl Icon { + /// All icons, in declaration order. + pub const ALL: &'static [Icon] = &[$(Icon::$v,)*]; + + /// Stable kebab-case name, e.g. `"format-painter"`. + pub fn name(self) -> &'static str { + match self { + $(Icon::$v => $n,)* + } + } + + /// Inverse of [`Icon::name`]. + pub fn from_name(s: &str) -> Option { + match s { + $($n => Some(Icon::$v),)* + _ => None, + } + } + } + }; +} + +icons! { + // Clipboard & slides + Paste => "paste", + Cut => "cut", + Copy => "copy", + FormatPainter => "format-painter", + NewSlide => "new-slide", + Layout => "layout", + ResetSlide => "reset-slide", + Section => "section", + DuplicateSlide => "duplicate-slide", + DeleteSlide => "delete-slide", + HideSlide => "hide-slide", + Slide => "slide", + // Font + FontGrow => "font-grow", + FontShrink => "font-shrink", + ClearFormatting => "clear-formatting", + Bold => "bold", + Italic => "italic", + Underline => "underline", + Strikethrough => "strikethrough", + Superscript => "superscript", + Subscript => "subscript", + CharSpacing => "char-spacing", + ChangeCase => "change-case", + Highlight => "highlight", + FontColor => "font-color", + TextShadow => "text-shadow", + // Paragraph + Bullets => "bullets", + Numbering => "numbering", + DecreaseIndent => "decrease-indent", + IncreaseIndent => "increase-indent", + LineSpacing => "line-spacing", + Columns => "columns", + AlignLeft => "align-left", + AlignCenter => "align-center", + AlignRight => "align-right", + Justify => "justify", + TextDirection => "text-direction", + AlignText => "align-text", + ConvertSmartArt => "convert-smart-art", + // Drawing + Shapes => "shapes", + Arrange => "arrange", + QuickStyles => "quick-styles", + ShapeFill => "shape-fill", + ShapeOutline => "shape-outline", + ShapeEffects => "shape-effects", + TextFill => "text-fill", + TextOutline => "text-outline", + TextEffects => "text-effects", + EditShape => "edit-shape", + MergeShapes => "merge-shapes", + // Insert + Table => "table", + Picture => "picture", + Screenshot => "screenshot", + Icons => "icons", + Models3d => "models-3d", + SmartArt => "smart-art", + Chart => "chart", + Zoom => "zoom", + Link => "link", + Action => "action", + Comment => "comment", + TextBox => "text-box", + HeaderFooter => "header-footer", + WordArt => "word-art", + DateTime => "date-time", + SlideNumber => "slide-number", + Object => "object", + Equation => "equation", + Symbol => "symbol", + Video => "video", + Audio => "audio", + ScreenRecording => "screen-recording", + Cameo => "cameo", + // Draw + Pen => "pen", + Pencil => "pencil", + HighlighterPen => "highlighter-pen", + Eraser => "eraser", + LassoSelect => "lasso-select", + InkToShape => "ink-to-shape", + InkToText => "ink-to-text", + InkToMath => "ink-to-math", + Ruler => "ruler", + // Design + Themes => "themes", + Variants => "variants", + Colors => "colors", + Fonts => "fonts", + BackgroundStyles => "background-styles", + SlideSize => "slide-size", + FormatBackground => "format-background", + DesignIdeas => "design-ideas", + // Transitions / animations + Preview => "preview", + EffectOptions => "effect-options", + Sound => "sound", + Duration => "duration", + ApplyToAll => "apply-to-all", + AnimEntrance => "anim-entrance", + AnimEmphasis => "anim-emphasis", + AnimExit => "anim-exit", + AnimPath => "anim-path", + AnimationPane => "animation-pane", + Trigger => "trigger", + AnimationPainter => "animation-painter", + AddAnimation => "add-animation", + // Slide show + PlayFromStart => "play-from-start", + PlayFromCurrent => "play-from-current", + PresenterView => "presenter-view", + CustomShow => "custom-show", + SetUpShow => "set-up-show", + RehearseTimings => "rehearse-timings", + Record => "record", + Subtitles => "subtitles", + Coach => "coach", + // Review + Spelling => "spelling", + Thesaurus => "thesaurus", + Accessibility => "accessibility", + Translate => "translate", + Language => "language", + NewComment => "new-comment", + DeleteComment => "delete-comment", + PrevComment => "prev-comment", + NextComment => "next-comment", + ShowComments => "show-comments", + Compare => "compare", + ReadOnly => "read-only", + // View + ViewNormal => "view-normal", + ViewOutline => "view-outline", + ViewSorter => "view-sorter", + ViewNotesPage => "view-notes-page", + ViewReading => "view-reading", + SlideMaster => "slide-master", + HandoutMaster => "handout-master", + NotesMaster => "notes-master", + Gridlines => "gridlines", + Guides => "guides", + Notes => "notes", + ZoomGlass => "zoom-glass", + FitToWindow => "fit-to-window", + Grayscale => "grayscale", + NewWindow => "new-window", + Macros => "macros", + // Arrange + BringForward => "bring-forward", + SendBackward => "send-backward", + BringToFront => "bring-to-front", + SendToBack => "send-to-back", + Group => "group", + Ungroup => "ungroup", + AlignObjects => "align-objects", + AlignObjLeft => "align-obj-left", + AlignObjCenter => "align-obj-center", + AlignObjRight => "align-obj-right", + AlignObjTop => "align-obj-top", + AlignObjMiddle => "align-obj-middle", + AlignObjBottom => "align-obj-bottom", + DistributeH => "distribute-h", + DistributeV => "distribute-v", + RotateLeft => "rotate-left", + RotateRight => "rotate-right", + FlipH => "flip-h", + FlipV => "flip-v", + SelectionPane => "selection-pane", + Rotate => "rotate", + // Picture + Crop => "crop", + Corrections => "corrections", + ColorAdjust => "color-adjust", + ArtisticEffects => "artistic-effects", + Transparency => "transparency", + CompressPictures => "compress-pictures", + ChangePicture => "change-picture", + ResetPicture => "reset-picture", + RemoveBackground => "remove-background", + AltText => "alt-text", + // Table + InsertAbove => "insert-above", + InsertBelow => "insert-below", + InsertLeft => "insert-left", + InsertRight => "insert-right", + DeleteTable => "delete-table", + MergeCells => "merge-cells", + SplitCells => "split-cells", + Borders => "borders", + Shading => "shading", + // Chart + ChartElements => "chart-elements", + ChartStyles => "chart-styles", + SwitchRowCol => "switch-row-col", + EditData => "edit-data", + ChangeChartType => "change-chart-type", + // Media + Play => "play", + Pause => "pause", + Stop => "stop", + TrimMedia => "trim-media", + Volume => "volume", + Loop => "loop", + Bookmark => "bookmark", + // Window chrome + Home => "home", + Save => "save", + Undo => "undo", + Redo => "redo", + More => "more", + Search => "search", + Share => "share", + CommentsBubble => "comments-bubble", + File => "file", + Folder => "folder", + Export => "export", + Print => "print", + Settings => "settings", + Help => "help", + Info => "info", + Warning => "warning", + Lock => "lock", + Eye => "eye", + EyeOff => "eye-off", + Close => "close", + Check => "check", + Plus => "plus", + Minus => "minus", + ChevronDown => "chevron-down", + ChevronUp => "chevron-up", + ChevronLeft => "chevron-left", + ChevronRight => "chevron-right", + DragHandle => "drag-handle", + Star => "star", + Image => "image", + Sparkle => "sparkle", + Palette => "palette", + Grid => "grid", + List => "list", + Collapse => "collapse", + Expand => "expand", + Pin => "pin", + // Status bar (small, monochrome) + NotesSmall => "notes-small", + CommentsSmall => "comments-small", + ViewNormalSmall => "view-normal-small", + ViewSorterSmall => "view-sorter-small", + ViewReadingSmall => "view-reading-small", + ViewShowSmall => "view-show-small", + FitSmall => "fit-small", + ZoomIn => "zoom-in", + ZoomOut => "zoom-out", +} + +/// Stroke width (in points) for an icon drawn at `size` points: ≈ size/16 at 16 pt, size/20 at +/// 32 pt and above, never thinner than 1.0. +fn stroke_width(size: f32) -> f32 { + let t = ((size - 16.0) / 16.0).clamp(0.0, 1.0); + (size / (16.0 + 4.0 * t)).max(1.0) +} + +/// Paint `icon` into `rect` (square or not; draw centred in the largest square). `ink` is the +/// foreground (text) colour for the current theme; accents are fixed colours (see above), but when +/// `disabled` is true draw everything in `ink` at ~35% alpha. +pub fn paint(painter: &egui::Painter, rect: egui::Rect, icon: Icon, ink: egui::Color32, disabled: bool) { + let size = rect.width().min(rect.height()); + if !size.is_finite() || size <= 0.5 { + return; + } + let origin = rect.center() - vec2(size * 0.5, size * 0.5); + let pen = Pen { p: painter, o: origin, k: size / 24.0, sw: stroke_width(size), ink, dis: disabled }; + draw(&pen, icon); +} + +// --------------------------------------------------------------------------------------------- +// Drawing toolkit +// --------------------------------------------------------------------------------------------- + +/// A logical colour, resolved against the theme ink and the disabled flag. +#[derive(Clone, Copy)] +enum C { + Ink(f32), + Acc(Color32, f32), + White, + None, +} + +impl C { + fn a(self, f: f32) -> C { + match self { + C::Ink(a) => C::Ink(a * f), + C::Acc(c, a) => C::Acc(c, a * f), + other => other, + } + } +} + +const K: C = C::Ink(1.0); +const B: C = C::Acc(BLUE, 1.0); +const O: C = C::Acc(ORANGE, 1.0); +const G: C = C::Acc(GREEN, 1.0); +const PU: C = C::Acc(PURPLE, 1.0); +const R: C = C::Acc(RED, 1.0); +const Y: C = C::Acc(GOLD, 1.0); +const D: C = C::Acc(DARK, 1.0); +const W: C = C::White; +const N: C = C::None; + +/// Light tint of an accent, for fills behind outlines. +fn t(c: C) -> C { + c.a(0.25) +} + +type P2 = (f32, f32); + +#[derive(Clone, Copy)] +enum Glyph { + Plus, + Cross, + Check, +} + +struct Pen<'a> { + p: &'a Painter, + o: Pos2, + /// Points per design unit. + k: f32, + /// Base stroke width in points. + sw: f32, + ink: Color32, + dis: bool, +} + +fn rad(deg: f32) -> f32 { + deg * PI / 180.0 +} + +fn arc_pts(c: P2, rx: f32, ry: f32, a0: f32, a1: f32) -> Vec { + let n = (((a1 - a0).abs() / 9.0).ceil() as usize).max(4); + (0..=n) + .map(|i| { + let a = rad(a0 + (a1 - a0) * i as f32 / n as f32); + (c.0 + rx * a.cos(), c.1 + ry * a.sin()) + }) + .collect() +} + +fn cubic_pts(p0: P2, p1: P2, p2: P2, p3: P2, n: usize) -> Vec { + (0..=n) + .map(|i| { + let t = i as f32 / n as f32; + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + (a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0, a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1) + }) + .collect() +} + +fn quad_pts(p0: P2, p1: P2, p2: P2, n: usize) -> Vec { + (0..=n) + .map(|i| { + let t = i as f32 / n as f32; + let u = 1.0 - t; + (u * u * p0.0 + 2.0 * u * t * p1.0 + t * t * p2.0, u * u * p0.1 + 2.0 * u * t * p1.1 + t * t * p2.1) + }) + .collect() +} + +/// Rounds the corners of a polyline by `r` design units. +fn round_pts(pts: &[P2], r: f32, closed: bool) -> Vec { + let n = pts.len(); + if n < 3 || r <= 0.0 { + return pts.to_vec(); + } + let mut out = Vec::with_capacity(n * 6); + for i in 0..n { + let cur = pts[i]; + let interior = closed || (i > 0 && i + 1 < n); + if !interior { + out.push(cur); + continue; + } + let prev = pts[(i + n - 1) % n]; + let next = pts[(i + 1) % n]; + let d1 = ((prev.0 - cur.0).powi(2) + (prev.1 - cur.1).powi(2)).sqrt(); + let d2 = ((next.0 - cur.0).powi(2) + (next.1 - cur.1).powi(2)).sqrt(); + if d1 < 1e-4 || d2 < 1e-4 { + out.push(cur); + continue; + } + let rr = r.min(d1 * 0.5).min(d2 * 0.5); + let a = (cur.0 + (prev.0 - cur.0) / d1 * rr, cur.1 + (prev.1 - cur.1) / d1 * rr); + let b = (cur.0 + (next.0 - cur.0) / d2 * rr, cur.1 + (next.1 - cur.1) / d2 * rr); + out.extend(quad_pts(a, cur, b, 4)); + } + out +} + +fn rrect_pts(x0: f32, y0: f32, x1: f32, y1: f32, r: f32) -> Vec { + round_pts(&[(x0, y0), (x1, y0), (x1, y1), (x0, y1)], r, true) +} + +/// Star polygon with `n` points. +fn star_pts(c: P2, ro: f32, ri: f32, n: usize, rot: f32) -> Vec { + (0..n * 2) + .map(|i| { + let r = if i % 2 == 0 { ro } else { ri }; + let a = rad(rot - 90.0 + i as f32 * 180.0 / n as f32); + (c.0 + r * a.cos(), c.1 + r * a.sin()) + }) + .collect() +} + +/// Transforms local points (u along `ang`, v across) to design coordinates around `c`. +fn tf(c: P2, ang: f32, pts: &[P2]) -> Vec { + let (s, co) = rad(ang).sin_cos(); + pts.iter().map(|&(u, v)| (c.0 + u * co - v * s, c.1 + u * s + v * co)).collect() +} + +fn point_in_tri(p: Pos2, a: Pos2, b: Pos2, c: Pos2) -> bool { + let d1 = (p.x - b.x) * (a.y - b.y) - (a.x - b.x) * (p.y - b.y); + let d2 = (p.x - c.x) * (b.y - c.y) - (b.x - c.x) * (p.y - c.y); + let d3 = (p.x - a.x) * (c.y - a.y) - (c.x - a.x) * (p.y - a.y); + let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(neg && pos) +} + +/// Ear-clipping triangulation of a simple polygon. +fn triangulate(pts: &[Pos2]) -> Vec<[u32; 3]> { + let n = pts.len(); + let mut tris = Vec::new(); + if n < 3 { + return tris; + } + let area: f32 = (0..n) + .map(|i| { + let a = pts[i]; + let b = pts[(i + 1) % n]; + a.x * b.y - b.x * a.y + }) + .sum(); + let mut idx: Vec = (0..n).collect(); + if area < 0.0 { + idx.reverse(); + } + while idx.len() > 3 { + let m = idx.len(); + let mut clipped = false; + for i in 0..m { + let (ia, ib, ic) = (idx[(i + m - 1) % m], idx[i], idx[(i + 1) % m]); + let (a, b, c) = (pts[ia], pts[ib], pts[ic]); + let cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + if cross <= 1e-7 { + continue; + } + let blocked = idx.iter().any(|&j| j != ia && j != ib && j != ic && point_in_tri(pts[j], a, b, c)); + if blocked { + continue; + } + tris.push([ia as u32, ib as u32, ic as u32]); + idx.remove(i); + clipped = true; + break; + } + if !clipped { + // Degenerate input: drop a vertex and carry on rather than loop forever. + idx.remove(0); + } + } + if let [a, b, c] = idx[..] { + tris.push([a as u32, b as u32, c as u32]); + } + tris +} + +impl Pen<'_> { + fn pt(&self, (x, y): P2) -> Pos2 { + pos2(self.o.x + x * self.k, self.o.y + y * self.k) + } + + fn v(&self, pts: &[P2]) -> Vec { + pts.iter().map(|&p| self.pt(p)).collect() + } + + fn col(&self, c: C) -> Color32 { + let dim = if self.dis { 0.35 } else { 1.0 }; + match c { + C::Ink(a) => self.ink.gamma_multiply(a * dim), + C::Acc(col, a) => { + if self.dis { + self.ink.gamma_multiply(a * dim) + } else { + col.gamma_multiply(a) + } + } + C::White => { + if self.dis { + // Glyphs on solid fills stay legible as a darker mark. + self.ink.gamma_multiply(0.6) + } else { + Color32::WHITE + } + } + C::None => Color32::TRANSPARENT, + } + } + + fn stroke(&self, c: C, w: f32) -> Stroke { + Stroke::new(self.sw * w, self.col(c)) + } + + // --- lines --- + + fn line_w(&self, pts: &[P2], c: C, w: f32) { + if pts.len() < 2 { + return; + } + let col = self.col(c); + if col == Color32::TRANSPARENT { + return; + } + let v = self.v(pts); + if let [a, b] = v[..] { + // Single segments get round caps: draw them as one convex capsule. + let d = b - a; + let len = d.length(); + let r = self.sw * w * 0.5; + if len < 1e-3 { + self.p.circle_filled(a, r, col); + return; + } + let ang = d.y.atan2(d.x); + let mut cap = Vec::with_capacity(20); + for (c, start) in [(b, ang - PI * 0.5), (a, ang + PI * 0.5)] { + for i in 0..=8 { + let t = start + PI * i as f32 / 8.0; + cap.push(c + vec2(t.cos(), t.sin()) * r); + } + } + self.p.add(PathShape::convex_polygon(cap, col, Stroke::NONE)); + return; + } + self.p.add(PathShape::line(v, Stroke::new(self.sw * w, col))); + } + + fn line(&self, pts: &[P2], c: C) { + self.line_w(pts, c, 1.0); + } + + fn seg(&self, a: P2, b: P2, c: C) { + self.line_w(&[a, b], c, 1.0); + } + + fn seg_w(&self, a: P2, b: P2, c: C, w: f32) { + self.line_w(&[a, b], c, w); + } + + fn rline(&self, pts: &[P2], r: f32, c: C) { + self.line(&round_pts(pts, r, false), c); + } + + fn rline_w(&self, pts: &[P2], r: f32, c: C, w: f32) { + self.line_w(&round_pts(pts, r, false), c, w); + } + + fn dashed(&self, pts: &[P2], c: C, dash: f32, gap: f32) { + let v = self.v(pts); + self.p.extend(Shape::dashed_line(&v, self.stroke(c, 1.0), dash * self.k, gap * self.k)); + } + + fn dashed_w(&self, pts: &[P2], c: C, dash: f32, gap: f32, w: f32) { + let v = self.v(pts); + self.p.extend(Shape::dashed_line(&v, self.stroke(c, w), dash * self.k, gap * self.k)); + } + + fn dotted(&self, pts: &[P2], c: C, spacing: f32, r: f32) { + let v = self.v(pts); + self.p.extend(Shape::dotted_line(&v, self.col(c), spacing * self.k, r * self.sw)); + } + + // --- filled shapes --- + + /// Any simple polygon: filled via triangulation, stroked as a closed path. + fn shape_w(&self, pts: &[P2], fill: C, stroke: C, w: f32) { + if pts.len() < 3 { + return; + } + let v = self.v(pts); + let fc = self.col(fill); + if fc != Color32::TRANSPARENT { + let mut mesh = Mesh::default(); + for &p in &v { + mesh.colored_vertex(p, fc); + } + for [a, b, c] in triangulate(&v) { + mesh.add_triangle(a, b, c); + } + self.p.add(Shape::mesh(mesh)); + // A hairline in the fill colour anti-aliases the mesh edge. + if matches!(stroke, C::None) { + self.p.add(PathShape::closed_line(v.clone(), Stroke::new(0.6_f32.min(self.sw * 0.5), fc))); + } + } + let sc = self.col(stroke); + if sc != Color32::TRANSPARENT { + self.p.add(PathShape::closed_line(v, Stroke::new(self.sw * w, sc))); + } + } + + fn shape(&self, pts: &[P2], fill: C, stroke: C) { + self.shape_w(pts, fill, stroke, 1.0); + } + + fn rshape(&self, pts: &[P2], r: f32, fill: C, stroke: C) { + self.shape(&round_pts(pts, r, true), fill, stroke); + } + + /// Convex polygon (anti-aliased fill). + fn poly(&self, pts: &[P2], fill: C, stroke: C) { + self.poly_w(pts, fill, stroke, 1.0); + } + + fn poly_w(&self, pts: &[P2], fill: C, stroke: C, w: f32) { + let v = self.v(pts); + self.p.add(PathShape::convex_polygon(v, self.col(fill), self.stroke(stroke, w))); + } + + fn rect_w(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32, fill: C, stroke: C, w: f32) { + let rect = Rect::from_min_max(self.pt((x0, y0)), self.pt((x1, y1))); + self.p.rect(rect, r * self.k, self.col(fill), self.stroke(stroke, w), StrokeKind::Middle); + } + + fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32, fill: C, stroke: C) { + self.rect_w(x0, y0, x1, y1, r, fill, stroke, 1.0); + } + + fn circle_w(&self, c: P2, r: f32, fill: C, stroke: C, w: f32) { + self.p.circle(self.pt(c), r * self.k, self.col(fill), self.stroke(stroke, w)); + } + + fn circle(&self, c: P2, r: f32, fill: C, stroke: C) { + self.circle_w(c, r, fill, stroke, 1.0); + } + + fn dot(&self, c: P2, r: f32, fill: C) { + self.p.circle_filled(self.pt(c), r * self.k, self.col(fill)); + } + + fn ellipse(&self, c: P2, rx: f32, ry: f32, fill: C, stroke: C) { + let mut pts = arc_pts(c, rx, ry, 0.0, 360.0); + pts.pop(); + self.poly(&pts, fill, stroke); + } + + // --- arcs and arrows --- + + fn arc(&self, c: P2, r: f32, a0: f32, a1: f32, col: C) { + self.line(&arc_pts(c, r, r, a0, a1), col); + } + + /// Filled arrow head with its tip at `tip`, pointing along `dir` degrees. + fn head(&self, tip: P2, dir: f32, size: f32, c: C) { + let (s, co) = rad(dir).sin_cos(); + let hw = size * 0.62; + let back = (tip.0 - co * size, tip.1 - s * size); + self.poly(&[tip, (back.0 - s * hw, back.1 + co * hw), (back.0 + s * hw, back.1 - co * hw)], c, N); + } + + fn head_size(&self) -> f32 { + (2.2 * self.sw / self.k).max(3.6) + } + + fn arrow(&self, a: P2, b: P2, c: C) { + let dir = (b.1 - a.1).atan2(b.0 - a.0); + let hs = self.head_size(); + let end = (b.0 - dir.cos() * hs * 0.7, b.1 - dir.sin() * hs * 0.7); + self.seg(a, end, c); + self.head(b, dir.to_degrees(), hs, c); + } + + fn arrow2(&self, a: P2, b: P2, c: C) { + let dir = (b.1 - a.1).atan2(b.0 - a.0); + let hs = self.head_size(); + let s = (a.0 + dir.cos() * hs * 0.7, a.1 + dir.sin() * hs * 0.7); + let e = (b.0 - dir.cos() * hs * 0.7, b.1 - dir.sin() * hs * 0.7); + self.seg(s, e, c); + self.head(b, dir.to_degrees(), hs, c); + self.head(a, dir.to_degrees() + 180.0, hs, c); + } + + /// Polyline ending in an arrow head. + fn path_arrow(&self, pts: &[P2], c: C) { + let n = pts.len(); + if n < 2 { + return; + } + let a = pts[n - 2]; + let b = pts[n - 1]; + let dir = (b.1 - a.1).atan2(b.0 - a.0); + let hs = self.head_size(); + let mut body = pts.to_vec(); + body[n - 1] = (b.0 - dir.cos() * hs * 0.7, b.1 - dir.sin() * hs * 0.7); + self.line(&body, c); + self.head(b, dir.to_degrees(), hs, c); + } + + /// Circular arc from `a0` to `a1` degrees with an arrow head at the `a1` end. + fn arc_arrow(&self, c: P2, r: f32, a0: f32, a1: f32, col: C) { + let hs = self.head_size(); + let sign = if a1 >= a0 { 1.0 } else { -1.0 }; + let back = (hs * 0.6 / r).to_degrees() * sign; + self.line(&arc_pts(c, r, r, a0, a1 - back), col); + let tip_a = rad(a1); + let tip = (c.0 + r * tip_a.cos(), c.1 + r * tip_a.sin()); + // Tangent direction of travel, nudged inward to sit on the arc. + let dir = a1 + 90.0 * sign - (hs * 0.5 / r).to_degrees() * sign; + self.head(tip, dir, hs, col); + } + + fn curve(&self, p0: P2, p1: P2, p2: P2, p3: P2, c: C) { + self.line(&cubic_pts(p0, p1, p2, p3, 24), c); + } + + fn curve_w(&self, p0: P2, p1: P2, p2: P2, p3: P2, c: C, w: f32) { + self.line_w(&cubic_pts(p0, p1, p2, p3, 24), c, w); + } + + // --- text --- + + fn text(&self, s: &str, at: P2, size: f32, c: C) { + self.p.text(self.pt(at), Align2::CENTER_CENTER, s, FontId::proportional(size * self.k), self.col(c)); + } + + fn text_bold(&self, s: &str, at: P2, size: f32, c: C) { + let d = 0.32 * self.sw / self.k; + let reps: &[f32] = if self.dis { &[0.0] } else { &[-d, 0.0, d] }; + for &dx in reps { + self.text(s, (at.0 + dx, at.1), size, c); + } + } + + fn text_rot(&self, s: &str, at: P2, size: f32, c: C, angle_deg: f32) { + let col = self.col(c); + let galley = self.p.layout_no_wrap(s.to_owned(), FontId::proportional(size * self.k), col); + let rot = egui::emath::Rot2::from_angle(rad(angle_deg)); + let pos = self.pt(at) - rot * (galley.size() * 0.5); + self.p.add(TextShape::new(pos, galley, col).with_angle(rad(angle_deg))); + } + + // --- composite parts --- + + /// Round badge with a white glyph (outline + ink glyph when disabled). + fn badge(&self, c: P2, r: f32, fill: C, glyph: Glyph) { + let gc = if self.dis { + self.circle(c, r, N, fill); + K + } else { + self.circle(c, r, fill, N); + W + }; + let g = r * 0.5; + let w = 0.95; + match glyph { + Glyph::Plus => { + self.seg_w((c.0 - g, c.1), (c.0 + g, c.1), gc, w); + self.seg_w((c.0, c.1 - g), (c.0, c.1 + g), gc, w); + } + Glyph::Cross => { + let g = g * 0.85; + self.seg_w((c.0 - g, c.1 - g), (c.0 + g, c.1 + g), gc, w); + self.seg_w((c.0 + g, c.1 - g), (c.0 - g, c.1 + g), gc, w); + } + Glyph::Check => { + self.line_w(&[(c.0 - g, c.1 + 0.1), (c.0 - g * 0.25, c.1 + g * 0.75), (c.0 + g, c.1 - g * 0.6)], gc, w); + } + } + } + + /// Landscape slide frame. + fn frame(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + self.rect(x0, y0, x1, y1, 1.5, N, K); + } + + /// Document page with a folded top-right corner. + fn page(&self, x0: f32, y0: f32, x1: f32, y1: f32, fill: C, stroke: C) { + let f = ((x1 - x0) * 0.32).min(5.0); + self.rshape(&[(x0, y0), (x1 - f, y0), (x1, y0 + f), (x1, y1), (x0, y1)], 1.2, fill, stroke); + self.line(&[(x1 - f, y0), (x1 - f, y0 + f), (x1, y0 + f)], stroke); + } + + /// Picture frame with sun and hills. + fn picture(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + let (w, h) = (x1 - x0, y1 - y0); + let m = w.min(h); + self.rect(x0, y0, x1, y1, 1.5, N, K); + self.dot((x1 - w * 0.27, y0 + h * 0.3), m * 0.12, Y); + let base = y1 - h * 0.14; + self.poly(&[(x0 + w * 0.12, base), (x0 + w * 0.38, y0 + h * 0.36), (x0 + w * 0.64, base)], G, N); + self.poly(&[(x0 + w * 0.48, base), (x0 + w * 0.68, y0 + h * 0.56), (x1 - w * 0.12, base)], G.a(0.7), N); + } + + fn magnifier(&self, c: P2, r: f32, lens_fill: C, col: C) { + self.circle(c, r, lens_fill, col); + let d = r * 0.707; + self.seg_w((c.0 + d + 0.6, c.1 + d + 0.6), (c.0 + r * 1.55, c.1 + r * 1.55), col, 1.5); + } + + fn bubble_pts(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec { + let r = 2.5; + let mut pts = arc_pts((x0 + r, y0 + r), r, r, 180.0, 270.0); + pts.extend(arc_pts((x1 - r, y0 + r), r, r, 270.0, 360.0)); + pts.extend(arc_pts((x1 - r, y1 - r), r, r, 0.0, 90.0)); + let tw = ((x1 - x0) * 0.22).clamp(2.5, 4.5); + pts.push((x0 + r + 0.8 + tw, y1)); + pts.push((x0 + r - 0.2, y1 + 3.8)); + pts.push((x0 + r + 0.8, y1)); + pts.extend(arc_pts((x0 + r, y1 - r), r, r, 90.0, 180.0)); + pts + } + + fn bubble(&self, x0: f32, y0: f32, x1: f32, y1: f32, fill: C, stroke: C) { + self.shape(&Self::bubble_pts(x0, y0, x1, y1), fill, stroke); + } + + fn person(&self, cx: f32, top: f32, s: f32, fill: C) { + self.dot((cx, top + 2.6 * s), 2.6 * s, fill); + let base = top + 12.0 * s; + self.poly(&arc_pts((cx, base), 5.2 * s, 5.0 * s, 180.0, 360.0), fill, N); + } + + fn star(&self, c: P2, ro: f32, fill: C, stroke: C) { + self.shape(&star_pts(c, ro, ro * 0.47, 5, 0.0), fill, stroke); + } + + fn sparkle(&self, c: P2, r: f32, fill: C) { + self.shape(&star_pts(c, r, r * 0.3, 4, 0.0), fill, N); + } + + fn gear(&self, c: P2, ro: f32, ri: f32, teeth: usize, fill: C, stroke: C) { + let step = 360.0 / teeth as f32; + let mut pts = Vec::with_capacity(teeth * 4); + for i in 0..teeth { + let base = i as f32 * step; + for (a, r) in [(-0.3, ri), (-0.17, ro), (0.17, ro), (0.3, ri)] { + let ang = rad(base + a * step); + pts.push((c.0 + r * ang.cos(), c.1 + r * ang.sin())); + } + } + self.shape(&pts, fill, stroke); + } + + fn speaker(&self, x: f32, cy: f32, fill: C, stroke: C) { + self.rshape( + &[(x, cy - 3.0), (x + 3.8, cy - 3.0), (x + 8.5, cy - 7.5), (x + 8.5, cy + 7.5), (x + 3.8, cy + 3.0), (x, cy + 3.0)], + 0.8, + fill, + stroke, + ); + } + + /// Table grid; `header` fills the first row. + fn table(&self, x0: f32, y0: f32, x1: f32, y1: f32, rows: usize, cols: usize, header: C) { + let rh = (y1 - y0) / rows as f32; + let cw = (x1 - x0) / cols as f32; + if !matches!(header, C::None) { + self.rect(x0, y0, x1, y0 + rh, 1.2, header, N); + } + for i in 1..rows { + let y = y0 + rh * i as f32; + self.seg_w((x0, y), (x1, y), K, 0.8); + } + for j in 1..cols { + let x = x0 + cw * j as f32; + self.seg_w((x, y0), (x, y1), K, 0.8); + } + self.rect(x0, y0, x1, y1, 1.2, N, K); + } + + /// Bar chart in the box. + fn bars(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + let w = (x1 - x0) / 3.0; + let h = y1 - y0; + for (i, (frac, c)) in [(0.5, B), (1.0, O), (0.72, G)].into_iter().enumerate() { + let bx = x0 + w * i as f32; + self.rect(bx + w * 0.16, y1 - h * frac, bx + w * 0.84, y1, 0.6, c, N); + } + self.seg((x0 - 0.5, y1 + 0.2), (x1 + 0.5, y1 + 0.2), K); + } + + /// A writing tool lying along `ang` degrees with its point at `tip`. + fn tool(&self, tip: P2, ang: f32, len: f32, hw: f32, cone: f32, body: C, lead: C, cap_len: f32, cap: C) { + let m = |pts: &[P2]| tf(tip, ang, pts); + self.poly(&m(&[(0.0, 0.0), (cone, -hw), (cone, hw)]), N, K); + self.poly(&m(&[(0.0, 0.0), (cone * 0.42, -hw * 0.42), (cone * 0.42, hw * 0.42)]), lead, N); + self.poly(&m(&[(cone, -hw), (len - cap_len, -hw), (len - cap_len, hw), (cone, hw)]), body, K); + if cap_len > 0.0 { + self.poly(&m(&[(len - cap_len, -hw), (len, -hw), (len, hw), (len - cap_len, hw)]), cap, K); + } + } + + /// Paint brush lying along `ang` with bristle tip at `tip`. + fn brush(&self, tip: P2, ang: f32, len: f32, bristle: C) { + let m = |pts: &[P2]| tf(tip, ang, pts); + self.shape(&m(&[(0.0, -0.4), (2.5, -2.2), (5.5, -2.2), (5.5, 2.2), (2.5, 2.2), (0.0, 0.4)]), bristle, K); + self.poly(&m(&[(5.5, -2.0), (7.8, -2.0), (7.8, 2.0), (5.5, 2.0)]), K, K); + self.line(&m(&[(7.8, -1.1), (len, -1.3), (len, 1.3), (7.8, 1.1)]), K); + } + + /// Small path-drawn digit 1–3 in a `3 × h` box. + fn digit(&self, d: u8, x: f32, y: f32, h: f32, c: C) { + let w = 0.8; + match d { + 1 => self.line_w(&[(x + 0.4, y + 1.2), (x + 2.0, y), (x + 2.0, y + h)], c, w), + 2 => self.line_w(&[(x, y + 1.0), (x + 0.7, y), (x + 2.4, y), (x + 3.1, y + 1.1), (x + 2.9, y + 2.2), (x, y + h), (x + 3.2, y + h)], c, w), + _ => self.line_w( + &[(x, y), (x + 3.0, y), (x + 1.3, y + h * 0.42), (x + 2.6, y + h * 0.5), (x + 3.1, y + h * 0.7), (x + 2.5, y + h), (x, y + h)], + c, + w, + ), + } + } + + /// Path-drawn capital A. + fn letter_a(&self, x0: f32, top: f32, x1: f32, bottom: f32, c: C, w: f32) { + let mx = (x0 + x1) * 0.5; + self.line_w(&[(x0, bottom), (mx, top), (x1, bottom)], c, w); + let f = 0.62; + let y = top + (bottom - top) * f; + let dx = (x1 - x0) * 0.5 * f; + self.seg_w((mx - dx, y), (mx + dx, y), c, w); + } + + /// Hand-drawn scribble in the box. + fn scribble(&self, x0: f32, y0: f32, x1: f32, y1: f32, c: C) { + let (w, h) = (x1 - x0, y1 - y0); + let p = |u: f32, v: f32| (x0 + w * u, y0 + h * v); + // A loopy cursive stroke (prolate cycloid), normalised into the box. + let raw: Vec = (0..=48) + .map(|i| { + let th = i as f32 / 48.0 * 4.0 * PI + 0.6; + (th - 1.9 * th.sin(), -1.9 * th.cos()) + }) + .collect(); + let (mut lx, mut hx, mut ly, mut hy) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN); + for &(x, y) in &raw { + lx = lx.min(x); + hx = hx.max(x); + ly = ly.min(y); + hy = hy.max(y); + } + let sx = (hx - lx).max(1e-3); + let sy = (hy - ly).max(1e-3); + let pts: Vec = raw.iter().map(|&(x, y)| p((x - lx) / sx, (y - ly) / sy)).collect(); + self.line(&pts, c); + } + + /// Isometric "layer" diamond. + fn layer(&self, cx: f32, cy: f32, hw: f32, hh: f32, fill: C, stroke: C) { + self.rshape(&[(cx, cy - hh), (cx + hw, cy), (cx, cy + hh), (cx - hw, cy)], 0.8, fill, stroke); + } + + /// Open book. + fn book(&self, fill: C, col: C) { + let left = { + let mut p = quad_pts((12.0, 7.0), (7.5, 4.0), (2.0, 5.0), 8); + p.extend(quad_pts((2.0, 18.5), (7.5, 17.5), (12.0, 20.5), 8)); + p + }; + let right = { + let mut p = quad_pts((12.0, 7.0), (16.5, 4.0), (22.0, 5.0), 8); + p.extend(quad_pts((22.0, 18.5), (16.5, 17.5), (12.0, 20.5), 8)); + p + }; + self.shape(&left, fill, col); + self.shape(&right, fill, col); + } + + fn monitor(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + self.rect(x0, y0, x1, y1, 1.5, N, K); + let cx = (x0 + x1) * 0.5; + self.seg((cx, y1), (cx, y1 + 3.2), K); + self.seg((cx - 4.0, y1 + 3.6), (cx + 4.0, y1 + 3.6), K); + } + + fn clock_badge(&self, c: P2, r: f32, fill: C) { + let hc = if self.dis { + self.circle(c, r, N, fill); + K + } else { + self.circle(c, r, fill, N); + W + }; + self.line_w(&[(c.0, c.1 - r * 0.6), c, (c.0 + r * 0.5, c.1 + r * 0.2)], hc, 0.9); + } + + fn gear_badge(&self, c: P2, r: f32, fill: C) { + self.gear(c, r, r * 0.74, 8, fill, N); + if self.dis { + self.gear(c, r, r * 0.74, 8, N, fill); + } + self.dot(c, r * 0.32, if self.dis { N } else { W }); + if self.dis { + self.circle(c, r * 0.32, N, fill); + } + } + + fn lock(&self, x0: f32, y0: f32, x1: f32, y1: f32) { + let cx = (x0 + x1) * 0.5; + let r = (x1 - x0) * 0.3; + let mut sh = vec![(cx - r, y0)]; + sh.extend(arc_pts((cx, y0 - r * 0.7), r, r, 180.0, 360.0)); + sh.push((cx + r, y0)); + self.line(&sh, K); + self.rect(x0, y0, x1, y1, 1.2, Y, K); + let ky = (y0 + y1) * 0.5; + self.dot((cx, ky - 0.4), 1.1, D); + self.seg_w((cx, ky), (cx, ky + 2.0), D, 0.9); + } + + fn cursor(&self, x: f32, y: f32, s: f32) { + let p = |u: f32, v: f32| (x + u * s, y + v * s); + let pts = [p(0.0, 0.0), p(0.0, 15.0), p(3.8, 11.6), p(6.4, 17.0), p(8.6, 16.0), p(6.1, 10.8), p(11.0, 10.8)]; + self.shape(&pts, K, K); + } +} + +// --------------------------------------------------------------------------------------------- +// The icons +// --------------------------------------------------------------------------------------------- + +#[expect(clippy::too_many_lines)] +fn draw(p: &Pen<'_>, icon: Icon) { + use Icon as I; + match icon { + // ----- Clipboard & slides ----- + I::Paste => { + p.rline(&[(10.5, 21.5), (3.5, 21.5), (3.5, 4.5), (17.0, 4.5), (17.0, 9.0)], 2.0, K); + p.rect(7.0, 2.5, 13.5, 6.5, 1.2, Y, K); + p.rect(10.5, 10.0, 21.0, 22.0, 1.2, t(B), B); + p.seg((13.0, 14.0), (18.5, 14.0), B); + p.seg((13.0, 18.0), (17.0, 18.0), B); + } + I::Cut => { + p.seg((8.6, 14.6), (16.5, 2.5), K); + p.seg((15.4, 14.6), (7.5, 2.5), K); + p.circle((6.8, 17.6), 3.3, N, O); + p.circle((17.2, 17.6), 3.3, N, O); + p.dot((12.0, 9.4), 1.0, K); + } + I::Copy => { + p.rline(&[(8.0, 16.0), (3.0, 16.0), (3.0, 2.5), (14.0, 2.5), (14.0, 7.0)], 1.5, K); + p.rect(8.0, 7.5, 20.5, 21.5, 1.5, t(B), B); + p.seg((11.0, 12.5), (17.5, 12.5), B); + p.seg((11.0, 16.5), (16.0, 16.5), B); + } + I::FormatPainter => { + p.rect(2.5, 3.0, 17.0, 9.0, 1.5, Y, K); + p.rline(&[(17.0, 6.0), (20.5, 6.0), (20.5, 11.5), (11.0, 11.5), (11.0, 14.0)], 1.2, K); + p.rect(9.0, 14.0, 13.0, 21.5, 1.0, K, K); + } + I::NewSlide => { + p.frame(2.0, 4.0, 19.5, 16.5); + p.seg((5.0, 8.0), (13.0, 8.0), B); + p.seg((5.0, 12.0), (10.0, 12.0), K.a(0.5)); + p.badge((18.0, 17.5), 5.0, G, Glyph::Plus); + } + I::Layout => { + p.frame(2.0, 3.5, 22.0, 20.5); + p.rect(5.0, 6.5, 19.0, 9.0, 0.8, B, N); + p.rect(5.0, 11.5, 11.0, 17.5, 0.8, t(G), G); + p.rect(13.0, 11.5, 19.0, 17.5, 0.8, t(O), O); + } + I::ResetSlide => { + p.frame(2.0, 3.5, 22.0, 20.5); + p.arc_arrow((12.0, 12.0), 4.6, -20.0, -290.0, B); + } + I::Section => { + p.rshape(&[(2.5, 2.5), (13.0, 2.5), (16.5, 5.75), (13.0, 9.0), (2.5, 9.0)], 0.6, PU, N); + p.rline(&[(5.0, 9.0), (5.0, 16.0), (7.5, 16.0)], 1.0, K); + p.rect(7.5, 12.0, 21.5, 20.5, 1.2, N, K); + p.seg((10.0, 15.5), (16.0, 15.5), K.a(0.5)); + } + I::DuplicateSlide => { + p.rline(&[(6.5, 15.5), (2.5, 15.5), (2.5, 3.5), (16.5, 3.5), (16.5, 8.0)], 1.5, K); + p.rect(6.5, 8.0, 21.5, 20.5, 1.5, t(B), B); + p.seg((14.0, 11.5), (14.0, 17.0), B); + p.seg((11.25, 14.25), (16.75, 14.25), B); + } + I::DeleteSlide => { + p.frame(2.0, 4.0, 19.5, 16.5); + p.seg((5.0, 8.0), (13.0, 8.0), B); + p.seg((5.0, 12.0), (10.0, 12.0), K.a(0.5)); + p.badge((18.0, 17.5), 5.0, R, Glyph::Cross); + } + I::HideSlide => { + p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K.a(0.55)); + p.seg((5.0, 8.0), (13.0, 8.0), K.a(0.55)); + p.seg((3.0, 21.0), (21.0, 3.0), R); + } + I::Slide => { + p.frame(2.0, 4.5, 22.0, 19.5); + p.seg((5.0, 8.5), (15.0, 8.5), B); + p.rect(5.0, 11.5, 19.0, 16.5, 0.8, t(B), N); + } + + // ----- Font ----- + I::FontGrow => { + p.letter_a(2.5, 4.0, 15.0, 20.5, K, 1.15); + p.poly(&[(16.0, 10.5), (22.5, 10.5), (19.25, 5.0)], B, N); + } + I::FontShrink => { + p.letter_a(3.0, 8.0, 13.5, 20.5, K, 1.0); + p.poly(&[(16.0, 5.0), (22.5, 5.0), (19.25, 10.5)], B, N); + } + I::ClearFormatting => { + p.letter_a(2.5, 3.0, 14.0, 17.5, K, 1.1); + let m = |pts: &[P2]| tf((17.0, 16.5), -45.0, pts); + p.poly(&m(&[(-5.5, -2.8), (0.0, -2.8), (0.0, 2.8), (-5.5, 2.8)]), R, R); + p.poly(&m(&[(0.0, -2.8), (5.5, -2.8), (5.5, 2.8), (0.0, 2.8)]), N, K); + } + I::Bold => p.text_bold("B", (12.0, 12.0), 22.0, K), + I::Italic => { + p.seg_w((10.5, 4.0), (18.5, 4.0), K, 1.1); + p.seg_w((5.5, 20.0), (13.5, 20.0), K, 1.1); + p.seg_w((14.5, 4.0), (9.5, 20.0), K, 1.1); + } + I::Underline => { + let mut pts = vec![(6.5, 3.0)]; + pts.extend(arc_pts((12.0, 11.5), 5.5, 5.5, 180.0, 0.0)); + pts.push((17.5, 3.0)); + p.line_w(&pts, K, 1.1); + p.seg_w((4.5, 21.0), (19.5, 21.0), B, 1.2); + } + I::Strikethrough => { + p.text("ab", (12.0, 11.0), 19.0, K); + p.seg_w((2.5, 13.0), (21.5, 13.0), R, 1.1); + } + I::Superscript => { + p.seg((3.0, 9.0), (13.0, 21.0), K); + p.seg((13.0, 9.0), (3.0, 21.0), K); + p.digit(2, 16.0, 2.5, 6.5, B); + } + I::Subscript => { + p.seg((3.0, 3.5), (13.0, 15.5), K); + p.seg((13.0, 3.5), (3.0, 15.5), K); + p.digit(2, 16.0, 15.0, 6.5, B); + } + I::CharSpacing => { + p.letter_a(2.0, 3.0, 10.5, 15.0, K, 1.0); + p.line(&[(13.5, 3.0), (17.75, 15.0), (22.0, 3.0)], K); + p.arrow2((2.5, 20.0), (21.5, 20.0), B); + } + I::ChangeCase => { + p.text("A", (8.0, 11.0), 19.0, K); + p.text("a", (17.5, 12.5), 16.0, B); + p.seg((3.0, 21.0), (21.0, 21.0), K.a(0.4)); + } + I::Highlight => { + let m = |pts: &[P2]| tf((8.0, 15.0), -45.0, pts); + p.poly(&m(&[(0.0, -1.2), (3.0, -3.0), (3.0, 3.0), (0.0, 1.4)]), Y, K); + p.poly(&m(&[(3.0, -3.0), (13.5, -3.0), (13.5, 3.0), (3.0, 3.0)]), N, K); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, Y, N); + } + I::FontColor => { + p.letter_a(4.5, 2.5, 19.5, 17.0, K, 1.1); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, R, N); + } + I::TextShadow => { + p.text("S", (14.0, 13.8), 22.0, K.a(0.3)); + p.text_bold("S", (11.0, 11.0), 22.0, K); + } + + // ----- Paragraph ----- + I::Bullets => { + for y in [5.5, 12.0, 18.5] { + p.dot((4.5, y), 1.8, B); + p.seg((9.0, y), (21.0, y), K); + } + } + I::Numbering => { + for (i, y) in [5.5_f32, 12.0, 18.5].into_iter().enumerate() { + p.digit(i as u8 + 1, 2.8, y - 2.6, 5.2, B); + p.seg((9.5, y), (21.0, y), K); + } + } + I::DecreaseIndent | I::IncreaseIndent => { + p.seg((3.0, 4.0), (21.0, 4.0), K); + p.seg((11.0, 9.5), (21.0, 9.5), K); + p.seg((11.0, 14.5), (21.0, 14.5), K); + p.seg((3.0, 20.0), (21.0, 20.0), K); + if icon == I::DecreaseIndent { + p.poly(&[(8.0, 8.0), (8.0, 16.0), (3.0, 12.0)], B, N); + } else { + p.poly(&[(3.0, 8.0), (3.0, 16.0), (8.0, 12.0)], B, N); + } + } + I::LineSpacing => { + for y in [5.0, 12.0, 19.0] { + p.seg((11.0, y), (21.0, y), K); + } + p.arrow2((5.0, 2.5), (5.0, 21.5), B); + } + I::Columns => { + for y in [4.5, 9.5, 14.5, 19.5] { + p.seg((2.5, y), (10.5, y), K); + p.seg((13.5, y), (21.5, y), K); + } + } + I::AlignLeft | I::AlignCenter | I::AlignRight | I::Justify => { + let rows: [(f32, f32); 4] = match icon { + I::AlignLeft => [(3.0, 21.0), (3.0, 15.0), (3.0, 21.0), (3.0, 13.0)], + I::AlignCenter => [(3.0, 21.0), (7.0, 17.0), (3.0, 21.0), (8.0, 16.0)], + I::AlignRight => [(3.0, 21.0), (9.0, 21.0), (3.0, 21.0), (11.0, 21.0)], + _ => [(3.0, 21.0), (3.0, 21.0), (3.0, 21.0), (3.0, 14.0)], + }; + for ((a, b), y) in rows.into_iter().zip([4.5, 9.5, 14.5, 19.5]) { + p.seg((a, y), (b, y), K); + } + } + I::TextDirection => { + // A capital A lying on its side. + p.line_w(&[(17.0, 5.5), (3.0, 12.0), (17.0, 18.5)], K, 1.1); + p.seg_w((11.7, 8.3), (11.7, 15.7), K, 1.1); + p.arrow((21.0, 3.5), (21.0, 20.5), B); + } + I::AlignText => { + p.rect(2.5, 3.0, 15.0, 21.0, 1.2, N, K); + p.seg((5.5, 10.0), (12.0, 10.0), K); + p.seg((5.5, 14.0), (10.5, 14.0), K); + p.arrow((19.5, 2.5), (19.5, 9.5), B); + p.arrow((19.5, 21.5), (19.5, 14.5), B); + } + I::ConvertSmartArt => { + for (y, x1) in [(4.0, 11.0), (8.0, 9.0), (12.0, 11.0)] { + p.seg((2.5, y), (x1, y), K); + } + p.path_arrow(&[(14.0, 6.0), (19.0, 6.0), (19.0, 12.5)], B); + p.rect(2.5, 15.5, 7.5, 21.5, 1.0, B, N); + p.rect(9.5, 15.5, 14.5, 21.5, 1.0, O, N); + p.rect(16.5, 15.5, 21.5, 21.5, 1.0, G, N); + } + + // ----- Drawing ----- + I::Shapes => { + p.rect(11.0, 2.5, 21.5, 13.0, 1.0, t(G), G); + p.rshape(&[(15.25, 11.0), (21.5, 21.5), (9.0, 21.5)], 0.8, t(O), O); + p.circle((8.0, 9.0), 5.5, t(B), B); + } + I::Arrange => { + p.rect(2.5, 2.5, 11.5, 11.5, 1.0, N, K); + p.rect(7.5, 7.5, 16.5, 16.5, 1.0, t(B), B); + p.rect(12.5, 12.5, 21.5, 21.5, 1.0, B, B); + } + I::QuickStyles => { + p.rect(3.0, 3.0, 11.0, 11.0, 1.5, B, N); + p.rect(13.0, 3.0, 21.0, 11.0, 1.5, N, O); + p.rect(3.0, 13.0, 11.0, 21.0, 1.5, t(G), G); + p.rect(13.0, 13.0, 21.0, 21.0, 1.5, PU, N); + } + I::ShapeFill => { + let m = |pts: &[P2]| tf((10.0, 10.0), 45.0, pts); + p.poly(&m(&[(-5.0, 0.0), (5.0, 0.0), (5.0, 5.0), (-5.0, 5.0)]), B, N); + p.rshape(&m(&[(-5.0, -5.0), (5.0, -5.0), (5.0, 5.0), (-5.0, 5.0)]), 0.8, N, K); + p.line(&quad_pts((5.2, 6.4), (5.5, 1.0), (10.0, 3.0), 10), K); + p.poly(&[(18.5, 8.5), (20.6, 12.6), (16.4, 12.6)], B, N); + p.dot((18.5, 13.4), 2.1, B); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, B, N); + } + I::ShapeOutline => { + p.tool((4.5, 15.5), -45.0, 17.0, 2.4, 4.2, N, K, 0.0, N); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, O, N); + } + I::ShapeEffects => { + p.rect(6.0, 8.0, 18.5, 20.5, 2.0, K.a(0.25), N); + p.rect(3.0, 5.0, 15.5, 17.5, 2.0, B, N); + p.sparkle((19.0, 5.0), 4.0, Y); + } + I::TextFill => { + p.letter_a(4.5, 2.5, 19.5, 17.0, B, 1.5); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, B, N); + } + I::TextOutline => { + let outer = [(3.5, 17.0), (9.6, 2.5), (14.4, 2.5), (20.5, 17.0), (16.6, 17.0), (15.2, 13.2), (8.8, 13.2), (7.4, 17.0)]; + p.shape_w(&outer, N, K, 0.8); + p.poly_w(&[(10.0, 10.0), (12.0, 5.0), (14.0, 10.0)], N, K, 0.8); + p.rect(2.5, 19.5, 21.5, 22.5, 0.8, O, N); + } + I::TextEffects => { + p.letter_a(3.0, 5.0, 17.0, 21.0, PU, 1.3); + p.sparkle((19.0, 5.0), 4.5, Y); + } + I::EditShape => { + let a = (4.0, 19.0); + let b = (11.0, 4.5); + let c = (20.0, 17.5); + let mut pts = vec![a, b]; + pts.extend(cubic_pts(b, (17.0, 4.0), (23.0, 11.0), c, 16)); + pts.extend(cubic_pts(c, (15.0, 22.0), (9.0, 17.0), a, 16)); + p.shape(&pts, t(B), K); + for q in [a, b, c] { + p.rect(q.0 - 1.6, q.1 - 1.6, q.0 + 1.6, q.1 + 1.6, 0.3, B, N); + } + } + I::MergeShapes => { + let mut lens = arc_pts((9.0, 12.0), 6.5, 6.5, -62.5, 62.5); + lens.extend(arc_pts((15.0, 12.0), 6.5, 6.5, 117.5, 242.5)); + p.poly(&lens, PU, N); + p.circle((9.0, 12.0), 6.5, N, K); + p.circle((15.0, 12.0), 6.5, N, K); + } + + // ----- Insert ----- + I::Table => p.table(2.5, 4.0, 21.5, 20.0, 3, 3, B), + I::Picture => p.picture(2.0, 4.0, 22.0, 20.0), + I::Screenshot => { + for (cx, cy, sx, sy) in [(2.5, 3.5, 1.0, 1.0), (21.5, 3.5, -1.0, 1.0), (2.5, 20.5, 1.0, -1.0), (21.5, 20.5, -1.0, -1.0)] { + p.rline(&[(cx, cy + 4.0 * sy), (cx, cy), (cx + 4.0 * sx, cy)], 1.0, K); + } + p.rect(6.5, 7.5, 17.5, 16.5, 1.0, t(B), B); + p.seg((6.5, 10.0), (17.5, 10.0), B); + } + I::Icons => { + p.person(6.75, 2.5, 0.75, B); + p.star((17.0, 7.0), 5.0, Y, N); + let heart: Vec = (0..40) + .map(|i| { + let tt = i as f32 / 40.0 * 2.0 * PI; + let x = 16.0 * tt.sin().powi(3); + let y = 13.0 * tt.cos() - 5.0 * (2.0 * tt).cos() - 2.0 * (3.0 * tt).cos() - (4.0 * tt).cos(); + (7.0 + x * 0.29, 17.0 - y * 0.29) + }) + .collect(); + p.shape(&heart, R, N); + p.rshape(&[(17.0, 12.5), (21.5, 17.0), (17.0, 21.5), (12.5, 17.0)], 0.8, G, N); + } + I::Models3d => { + let (top, ur, lr, bot, ll, ul, mid) = ((12.0, 2.5), (20.5, 7.2), (20.5, 16.8), (12.0, 21.5), (3.5, 16.8), (3.5, 7.2), (12.0, 11.9)); + p.poly(&[top, ur, mid, ul], t(B), N); + p.poly(&[ul, mid, bot, ll], B.a(0.55), N); + p.poly(&[mid, ur, lr, bot], B, N); + p.shape_w(&[top, ur, lr, bot, ll, ul], N, K, 1.0); + p.line(&[ul, mid, ur], K); + p.seg(mid, bot, K); + } + I::SmartArt => { + p.rect(8.5, 2.5, 15.5, 8.5, 1.0, G, N); + p.seg((12.0, 8.5), (12.0, 11.5), K); + p.rline(&[(5.5, 14.5), (5.5, 11.5), (18.5, 11.5), (18.5, 14.5)], 1.0, K); + p.rect(2.0, 14.5, 9.0, 20.5, 1.0, B, N); + p.rect(15.0, 14.5, 22.0, 20.5, 1.0, O, N); + } + I::Chart => p.bars(2.5, 4.0, 21.5, 20.5), + I::Zoom => { + p.rline(&[(10.5, 15.0), (2.0, 15.0), (2.0, 3.0), (18.0, 3.0), (18.0, 9.5)], 1.5, K); + p.seg((5.0, 6.5), (11.0, 6.5), K.a(0.5)); + p.magnifier((14.5, 14.5), 4.5, t(B), B); + } + I::Link => { + let link = |c: P2, col: C| { + let local = rrect_pts(-5.5, -2.8, 5.5, 2.8, 2.8); + p.shape(&tf(c, -45.0, &local), N, col); + }; + link((8.5, 15.5), K); + link((15.5, 8.5), B); + } + I::Action => { + p.rect(2.5, 3.5, 18.0, 12.5, 2.0, t(B), B); + p.cursor(11.5, 8.5, 0.82); + } + I::Comment => { + p.bubble(2.5, 3.5, 21.5, 16.5, t(Y), K); + p.seg((6.5, 8.0), (17.5, 8.0), K); + p.seg((6.5, 12.0), (14.0, 12.0), K); + } + I::TextBox => { + p.rect(3.5, 4.5, 20.5, 19.5, 0.5, N, K); + for (x, y) in [(3.5, 4.5), (20.5, 4.5), (3.5, 19.5), (20.5, 19.5)] { + p.rect(x - 1.5, y - 1.5, x + 1.5, y + 1.5, 0.3, B, N); + } + p.seg((7.5, 9.5), (16.5, 9.5), K); + p.seg((7.5, 14.5), (13.5, 14.5), K); + } + I::HeaderFooter => { + p.rect(4.0, 2.5, 20.0, 21.5, 1.2, N, K); + p.rect(6.5, 5.0, 17.5, 7.5, 0.6, B, N); + p.rect(6.5, 16.5, 17.5, 19.0, 0.6, B, N); + p.seg_w((7.0, 10.5), (17.0, 10.5), K.a(0.45), 0.8); + p.seg_w((7.0, 13.2), (14.5, 13.2), K.a(0.45), 0.8); + } + I::WordArt => { + p.text_rot("A", (13.5, 13.5), 22.0, PU, -10.0); + p.text_rot("A", (11.5, 11.5), 22.0, O, -10.0); + p.text_rot("A", (11.3, 11.5), 22.0, O, -10.0); + } + I::DateTime => { + p.rect(3.0, 5.0, 21.0, 21.0, 1.6, N, K); + p.rect(3.0, 5.0, 21.0, 9.5, 1.6, R, N); + p.rect(3.0, 5.0, 21.0, 21.0, 1.6, N, K); + p.seg((8.0, 2.8), (8.0, 6.5), K); + p.seg((16.0, 2.8), (16.0, 6.5), K); + for (i, (x, y)) in [(8.0, 13.5), (12.0, 13.5), (16.0, 13.5), (8.0, 17.5), (12.0, 17.5), (16.0, 17.5)].into_iter().enumerate() { + p.dot((x, y), 1.1, if i == 4 { B } else { K }); + } + } + I::SlideNumber => { + p.frame(2.0, 4.0, 22.0, 20.0); + p.seg((10.7, 7.5), (9.7, 16.5), B); + p.seg((15.0, 7.5), (14.0, 16.5), B); + p.seg((7.5, 10.5), (17.0, 10.5), B); + p.seg((7.0, 13.6), (16.5, 13.6), B); + } + I::Object => { + p.page(3.0, 2.0, 16.0, 19.0, N, K); + p.rect(11.5, 11.5, 21.5, 21.5, 1.6, B, N); + p.rect(14.2, 14.2, 18.8, 18.8, 0.6, N, W); + } + I::Equation => { + p.line_w(&[(18.5, 6.5), (18.0, 4.0), (6.0, 4.0), (12.5, 12.0), (6.0, 20.0), (18.0, 20.0), (18.5, 17.5)], B, 1.15); + } + I::Symbol => p.text("Ω", (12.0, 12.0), 22.0, K), + I::Video => { + p.rect(2.0, 6.0, 15.5, 18.0, 2.0, t(B), K); + p.rshape(&[(16.5, 10.5), (22.0, 7.0), (22.0, 17.0), (16.5, 13.5)], 0.6, N, K); + p.dot((6.0, 10.0), 1.2, R); + } + I::Audio => { + p.speaker(2.5, 12.0, t(PU), K); + p.arc((11.0, 12.0), 4.5, -45.0, 45.0, PU); + p.arc((11.0, 12.0), 8.5, -45.0, 45.0, PU); + } + I::ScreenRecording => { + p.monitor(2.0, 3.0, 22.0, 16.5); + p.circle((12.0, 9.75), 3.0, R, N); + } + I::Cameo => { + p.frame(2.0, 4.0, 22.0, 20.0); + p.circle((12.0, 12.0), 5.6, t(B), B); + p.dot((12.0, 10.4), 1.9, B); + p.poly(&arc_pts((12.0, 16.0), 3.4, 2.6, 180.0, 360.0), B, N); + } + + // ----- Draw tab ----- + I::Pen => { + p.tool((3.5, 20.5), -45.0, 23.0, 2.2, 5.0, B, K, 3.5, K); + } + I::Pencil => { + p.tool((3.5, 20.5), -45.0, 22.0, 2.6, 5.0, Y, K, 3.5, R); + } + I::HighlighterPen => { + let m = |pts: &[P2]| tf((6.0, 15.0), -45.0, pts); + p.poly(&m(&[(0.0, -1.0), (3.0, -3.2), (3.0, 3.2), (0.0, 2.2)]), Y, K); + p.poly(&m(&[(3.0, -3.6), (14.5, -3.6), (14.5, 3.6), (3.0, 3.6)]), t(Y), K); + p.rect(2.0, 19.5, 13.0, 22.5, 0.8, Y, N); + } + I::Eraser => { + let m = |pts: &[P2]| tf((12.5, 11.0), -45.0, pts); + p.poly(&m(&[(-8.0, -4.0), (-1.0, -4.0), (-1.0, 4.0), (-8.0, 4.0)]), R, N); + p.rshape(&m(&[(-8.0, -4.0), (8.0, -4.0), (8.0, 4.0), (-8.0, 4.0)]), 1.0, N, K); + p.seg((12.0, 21.0), (21.0, 21.0), K); + } + I::LassoSelect => { + let mut e = arc_pts((13.0, 9.0), 8.5, 6.0, 120.0, 480.0); + e.truncate(e.len().saturating_sub(1)); + p.dashed(&e, B, 2.6, 1.7); + p.curve((8.8, 14.2), (5.5, 16.5), (10.0, 19.0), (5.5, 22.0), K); + p.dot((8.8, 14.2), 1.4, K); + } + I::InkToShape => { + p.scribble(1.5, 2.0, 12.5, 11.0, K); + p.circle((16.5, 16.5), 5.0, t(B), B); + } + I::InkToText => { + p.scribble(1.5, 2.0, 12.5, 11.0, K); + p.letter_a(12.0, 9.0, 21.5, 21.5, B, 1.2); + } + I::InkToMath => { + p.scribble(1.5, 2.0, 12.5, 11.0, K); + p.line_w(&[(21.0, 11.0), (12.5, 11.0), (17.0, 16.25), (12.5, 21.5), (21.0, 21.5)], B, 1.1); + } + I::Ruler => { + let m = |pts: &[P2]| tf((12.0, 12.0), -45.0, pts); + p.rshape(&m(&[(-11.5, -4.0), (11.5, -4.0), (11.5, 4.0), (-11.5, 4.0)]), 0.8, Y.a(0.85), K); + for (i, u) in [-8.0_f32, -5.0, -2.0, 1.0, 4.0, 7.0].into_iter().enumerate() { + let l = if i % 2 == 0 { 3.2 } else { 1.8 }; + p.line_w(&m(&[(u, -4.0), (u, -4.0 + l)]), D, 0.8); + } + } + + // ----- Design ----- + I::Themes => { + p.rect(9.5, 2.5, 21.5, 14.5, 2.0, O, N); + p.rect(2.5, 8.5, 15.0, 21.5, 2.0, B, N); + p.text("Aa", (8.75, 15.0), 10.0, W); + } + I::Variants => { + p.rect(8.0, 2.5, 21.5, 11.0, 1.2, t(PU), PU); + p.rect(5.0, 7.0, 18.5, 15.5, 1.2, t(G), G); + p.rect(2.5, 12.0, 16.0, 21.0, 1.2, B, B); + } + I::Colors => { + for (i, c) in [R, Y, G, B].into_iter().enumerate() { + let x = 3.0 + i as f32 * 4.5; + p.rect(x, 4.0, x + 4.5, 20.0, 0.0, c, N); + } + p.rect(3.0, 4.0, 21.0, 20.0, 0.0, N, K); + } + I::Fonts => { + p.text("A", (8.0, 11.0), 20.0, K); + p.text("g", (17.0, 10.0), 18.0, B); + } + I::BackgroundStyles => { + p.poly(&[(2.7, 4.7), (21.3, 4.7), (2.7, 19.3)], t(B), N); + p.poly(&[(21.3, 4.7), (21.3, 19.3), (2.7, 19.3)], PU.a(0.7), N); + p.frame(2.0, 4.0, 22.0, 20.0); + } + I::SlideSize => { + p.rline(&[(17.0, 12.0), (17.0, 19.0), (2.5, 19.0), (2.5, 7.0), (10.0, 7.0)], 1.5, K); + p.arrow((9.0, 13.0), (21.5, 2.5), B); + } + I::FormatBackground => { + p.rline(&[(10.0, 17.0), (2.0, 17.0), (2.0, 4.0), (20.0, 4.0), (20.0, 9.0)], 1.5, K); + p.rect(4.5, 6.5, 13.5, 12.5, 0.6, t(PU), N); + p.brush((12.0, 20.5), -45.0, 13.0, Y); + } + I::DesignIdeas => { + p.circle((12.0, 9.5), 6.0, Y.a(0.5), K); + p.seg((9.6, 15.7), (9.6, 17.0), K); + p.seg((14.4, 15.7), (14.4, 17.0), K); + p.seg((9.5, 18.0), (14.5, 18.0), K); + p.seg((10.5, 21.0), (13.5, 21.0), K); + for a in [-160.0_f32, -125.0, -55.0, -20.0] { + let (s, c) = rad(a).sin_cos(); + p.seg((12.0 + c * 8.0, 9.5 + s * 8.0), (12.0 + c * 10.0, 9.5 + s * 10.0), Y); + } + } + + // ----- Transitions / animations ----- + I::Preview => { + p.circle((12.0, 12.0), 9.5, N, K); + p.rshape(&[(9.5, 7.0), (17.0, 12.0), (9.5, 17.0)], 0.8, G, N); + } + I::EffectOptions => { + p.rect(2.5, 8.5, 15.5, 21.5, 1.5, t(B), K); + p.arrow((9.0, 15.0), (21.5, 2.5), B); + } + I::Sound => { + let head = |c: P2| { + let pts = tf(c, -22.0, &arc_pts((0.0, 0.0), 3.0, 2.1, 0.0, 351.0)); + p.poly(&pts, PU, N); + }; + head((6.3, 18.0)); + head((16.3, 15.5)); + p.seg((9.0, 17.0), (9.0, 5.0), PU); + p.seg((19.0, 14.5), (19.0, 2.5), PU); + p.poly(&[(9.0, 4.5), (19.0, 2.0), (19.0, 5.5), (9.0, 8.0)], PU, N); + } + I::Duration => { + p.circle((12.0, 13.5), 8.0, N, K); + p.rect(10.0, 2.0, 14.0, 4.0, 0.6, K, N); + p.seg((12.0, 4.0), (12.0, 5.5), K); + p.seg((12.0, 13.5), (15.8, 9.0), B); + p.dot((12.0, 13.5), 1.3, B); + } + I::ApplyToAll => { + p.rline(&[(6.0, 7.5), (6.0, 3.0), (21.5, 3.0), (21.5, 13.5), (17.5, 13.5)], 1.5, K); + p.rect(2.5, 7.5, 17.5, 18.0, 1.5, N, K); + p.badge((18.0, 18.0), 4.8, G, Glyph::Check); + } + I::AnimEntrance => { + p.star((13.5, 12.5), 8.5, G, N); + p.seg((1.5, 9.0), (4.5, 9.0), K); + p.seg((0.8, 13.5), (4.0, 13.5), K); + } + I::AnimEmphasis => { + p.star((12.0, 12.5), 9.5, Y, N); + } + I::AnimExit => { + p.star((10.5, 12.5), 8.5, R, N); + p.seg((19.5, 9.0), (22.5, 9.0), K); + p.seg((20.0, 13.5), (23.2, 13.5), K); + } + I::AnimPath => { + let pts = cubic_pts((4.5, 19.5), (3.0, 6.0), (20.0, 19.0), (19.5, 5.0), 60); + p.dotted(&pts[6..pts.len().saturating_sub(4)], K, 2.4, 0.55); + p.head((19.5, 4.0), -90.0, 4.0, K); + p.circle((4.5, 19.5), 2.4, G, N); + } + I::AnimationPane => { + p.rect(2.5, 3.0, 21.5, 21.0, 1.5, N, K); + p.seg((11.0, 3.0), (11.0, 21.0), K); + p.rect(4.5, 7.0, 8.5, 12.0, 0.5, t(B), N); + for (y, c) in [(7.5, G), (12.0, Y), (16.5, R)] { + p.seg_w((13.5, y), (19.0, y), c, 1.4); + } + } + I::Trigger => { + p.rshape(&[(14.5, 2.0), (5.0, 13.5), (11.5, 13.5), (9.5, 22.0), (19.0, 9.5), (12.5, 9.5)], 0.6, Y, K); + } + I::AnimationPainter => { + p.star((7.5, 7.5), 6.0, G, N); + p.brush((12.0, 12.0), 45.0, 13.0, Y); + } + I::AddAnimation => { + p.star((10.5, 11.0), 9.0, Y, N); + p.badge((18.5, 18.5), 4.8, G, Glyph::Plus); + } + + // ----- Slide show ----- + I::PlayFromStart => { + p.frame(2.0, 4.0, 22.0, 20.0); + p.rect(7.0, 8.0, 9.2, 16.0, 0.5, G, N); + p.rshape(&[(11.0, 8.0), (17.5, 12.0), (11.0, 16.0)], 0.6, G, N); + } + I::PlayFromCurrent => { + p.frame(2.0, 4.0, 22.0, 20.0); + p.rshape(&[(9.5, 8.0), (16.0, 12.0), (9.5, 16.0)], 0.6, G, N); + } + I::PresenterView => { + p.monitor(2.0, 3.0, 22.0, 16.5); + p.rect(4.5, 5.5, 13.0, 12.0, 0.6, t(B), B); + p.seg_w((15.0, 6.5), (19.5, 6.5), K, 0.8); + p.seg_w((15.0, 9.5), (19.5, 9.5), K, 0.8); + p.seg_w((15.0, 12.5), (18.0, 12.5), K, 0.8); + } + I::CustomShow => { + p.rect(2.5, 2.5, 12.5, 8.0, 1.0, N, K); + p.rect(2.5, 9.5, 12.5, 15.0, 1.0, O, O); + p.rect(2.5, 16.5, 12.5, 22.0, 1.0, N, K); + p.rshape(&[(15.5, 8.0), (21.5, 12.25), (15.5, 16.5)], 0.6, G, N); + } + I::SetUpShow => { + p.frame(2.0, 4.0, 19.5, 16.5); + p.seg((5.0, 8.0), (13.0, 8.0), K.a(0.5)); + p.gear_badge((17.5, 17.5), 5.5, B); + } + I::RehearseTimings => { + p.frame(2.0, 4.0, 19.5, 16.5); + p.seg((5.0, 8.0), (13.0, 8.0), K.a(0.5)); + p.clock_badge((17.5, 17.5), 5.2, B); + } + I::Record => { + p.circle((12.0, 12.0), 9.5, N, K); + p.circle((12.0, 12.0), 5.5, R, N); + } + I::Subtitles => { + p.rect(2.0, 4.5, 22.0, 19.5, 2.0, N, K); + p.seg((5.5, 12.0), (11.0, 12.0), B); + p.seg((13.5, 12.0), (18.5, 12.0), B); + p.seg((5.5, 16.0), (8.5, 16.0), B); + p.seg((11.0, 16.0), (18.5, 16.0), B); + } + I::Coach => { + p.person(9.0, 5.0, 1.2, t(B)); + p.circle((9.0, 8.1), 3.1, N, B); + p.line(&arc_pts((9.0, 19.4), 6.2, 6.0, 180.0, 360.0), B); + p.star((18.5, 6.0), 4.2, Y, N); + } + + // ----- Review ----- + I::Spelling => { + p.text("abc", (11.5, 7.0), 12.0, K); + p.line_w(&[(6.0, 16.0), (10.0, 20.0), (19.5, 11.0)], G, 1.3); + } + I::Thesaurus => { + p.rect(8.0, 2.5, 19.5, 21.5, 1.2, t(B), N); + p.rect(4.5, 2.5, 19.5, 21.5, 1.5, N, K); + p.seg((8.0, 2.5), (8.0, 21.5), K); + p.poly(&[(13.5, 2.5), (17.0, 2.5), (17.0, 9.0), (15.25, 7.5), (13.5, 9.0)], R, N); + } + I::Accessibility => { + p.circle((12.0, 12.0), 9.8, N, B); + p.dot((12.0, 6.5), 1.6, B); + p.seg((6.8, 9.6), (17.2, 9.6), B); + p.seg((12.0, 9.6), (12.0, 13.8), B); + p.line(&[(9.0, 18.5), (12.0, 13.8), (15.0, 18.5)], B); + } + I::Translate => { + p.rect(10.0, 9.0, 21.5, 20.5, 2.0, O, N); + p.rect(2.5, 3.5, 14.0, 15.0, 2.0, B, N); + p.letter_a(5.5, 5.8, 11.0, 12.8, W, 0.9); + p.seg_w((17.75, 10.8), (17.75, 11.9), W, 0.8); + p.seg_w((15.2, 12.9), (20.3, 12.9), W, 0.8); + p.seg_w((15.6, 14.4), (19.9, 18.9), W, 0.8); + p.seg_w((19.9, 14.4), (15.6, 18.9), W, 0.8); + } + I::Language => { + p.circle((12.0, 12.0), 9.5, N, B); + p.ellipse((12.0, 12.0), 4.2, 9.5, N, B); + p.seg_w((2.5, 12.0), (21.5, 12.0), B, 0.8); + p.seg_w((4.3, 7.0), (19.7, 7.0), B, 0.8); + p.seg_w((4.3, 17.0), (19.7, 17.0), B, 0.8); + } + I::NewComment => { + p.bubble(2.0, 3.0, 19.5, 15.0, N, K); + p.badge((18.0, 17.5), 5.0, G, Glyph::Plus); + } + I::DeleteComment => { + p.bubble(2.0, 3.0, 19.5, 15.0, N, K); + p.badge((18.0, 17.5), 5.0, R, Glyph::Cross); + } + I::PrevComment => { + p.bubble(2.5, 3.0, 21.5, 17.0, N, K); + p.arrow((16.5, 10.0), (7.0, 10.0), B); + } + I::NextComment => { + p.bubble(2.5, 3.0, 21.5, 17.0, N, K); + p.arrow((7.5, 10.0), (17.0, 10.0), B); + } + I::ShowComments => { + p.rline(&[(8.0, 7.5), (8.0, 2.5), (21.5, 2.5), (21.5, 13.0), (17.0, 13.0)], 2.0, K); + p.bubble(2.5, 7.5, 16.5, 17.5, t(Y), K); + p.seg((5.5, 11.0), (13.5, 11.0), K); + p.seg((5.5, 14.2), (11.0, 14.2), K); + } + I::Compare => { + p.rect(2.5, 2.5, 11.0, 15.5, 1.2, N, K); + p.rect(13.0, 8.5, 21.5, 21.5, 1.2, t(B), B); + p.arrow((13.0, 4.0), (21.5, 4.0), K); + p.arrow((11.0, 20.0), (2.5, 20.0), K); + } + I::ReadOnly => { + p.page(3.0, 2.0, 16.0, 20.5, N, K); + p.lock(13.0, 15.0, 21.5, 22.0); + } + + // ----- View ----- + I::ViewNormal => { + p.rect(2.0, 3.5, 22.0, 20.5, 1.5, N, K); + p.seg((8.0, 3.5), (8.0, 20.5), K); + for y in [6.0, 10.5, 15.0] { + p.rect(3.9, y, 6.2, y + 2.6, 0.3, K.a(0.55), N); + } + p.rect(10.0, 6.0, 20.0, 15.0, 0.6, t(B), B); + p.seg_w((10.0, 18.0), (17.0, 18.0), K.a(0.5), 0.8); + } + I::ViewOutline => { + p.rect(2.0, 3.5, 22.0, 20.5, 1.5, N, K); + p.seg((11.0, 3.5), (11.0, 20.5), K); + p.seg_w((4.0, 7.0), (9.0, 7.0), K, 0.8); + p.seg_w((5.5, 10.0), (9.0, 10.0), K, 0.8); + p.seg_w((5.5, 13.0), (9.0, 13.0), K, 0.8); + p.seg_w((4.0, 16.0), (9.0, 16.0), K, 0.8); + p.rect(13.0, 7.0, 20.0, 13.0, 0.6, t(B), B); + } + I::ViewSorter => { + for (i, (x0, x1)) in [(2.5, 11.0), (13.0, 21.5)].into_iter().enumerate() { + for (j, (y0, y1)) in [(4.0, 10.5), (13.5, 20.0)].into_iter().enumerate() { + if i == 0 && j == 0 { + p.rect(x0, y0, x1, y1, 0.8, t(B), B); + } else { + p.rect(x0, y0, x1, y1, 0.8, N, K); + } + } + } + } + I::ViewNotesPage => { + p.rect(5.0, 2.0, 19.0, 22.0, 1.2, N, K); + p.rect(7.5, 4.5, 16.5, 10.5, 0.6, t(B), B); + p.seg_w((7.5, 14.0), (16.5, 14.0), K, 0.8); + p.seg_w((7.5, 17.5), (14.0, 17.5), K, 0.8); + } + I::ViewReading => { + p.book(t(B), K); + p.seg((12.0, 7.0), (12.0, 20.5), K); + } + I::SlideMaster => { + p.rect(2.0, 2.5, 15.0, 10.5, 1.2, t(PU), PU); + p.seg((4.5, 5.5), (11.0, 5.5), PU); + p.rline(&[(5.0, 10.5), (5.0, 19.5), (9.0, 19.5)], 1.0, K); + p.seg((5.0, 14.0), (9.0, 14.0), K); + p.rect(9.0, 12.0, 17.0, 16.0, 0.8, N, K); + p.rect(9.0, 17.5, 17.0, 21.5, 0.8, N, K); + } + I::HandoutMaster => { + p.rect(4.0, 2.0, 20.0, 22.0, 1.2, N, K); + for (x, y) in [(6.5, 4.5), (12.75, 4.5), (6.5, 11.0), (12.75, 11.0)] { + p.rect(x, y, x + 4.75, y + 4.5, 0.4, t(PU), PU); + } + p.seg_w((6.5, 19.0), (17.5, 19.0), K, 0.8); + } + I::NotesMaster => { + p.rline(&[(8.0, 4.0), (8.0, 2.0), (21.0, 2.0), (21.0, 19.0), (16.0, 19.0)], 1.2, PU); + p.rect(3.0, 4.5, 16.0, 22.0, 1.2, N, K); + p.rect(5.5, 7.0, 13.5, 12.0, 0.5, t(PU), PU); + p.seg_w((5.5, 15.5), (13.5, 15.5), K, 0.8); + p.seg_w((5.5, 18.8), (11.5, 18.8), K, 0.8); + } + I::Gridlines => { + for v in [9.0, 15.0] { + p.seg_w((v, 3.0), (v, 21.0), K.a(0.6), 0.7); + p.seg_w((3.0, v), (21.0, v), K.a(0.6), 0.7); + } + p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + } + I::Guides => { + p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + p.dashed(&[(12.0, 1.5), (12.0, 22.5)], B, 2.2, 1.6); + p.dashed(&[(1.5, 12.0), (22.5, 12.0)], B, 2.2, 1.6); + } + I::Notes => { + p.rect(4.0, 4.0, 20.0, 21.5, 1.5, t(Y), K); + for x in [8.0, 12.0, 16.0] { + p.seg((x, 2.3), (x, 5.8), K); + } + p.seg_w((7.5, 10.5), (16.5, 10.5), K, 0.85); + p.seg_w((7.5, 14.0), (16.5, 14.0), K, 0.85); + p.seg_w((7.5, 17.5), (13.5, 17.5), K, 0.85); + } + I::ZoomGlass => p.magnifier((10.0, 10.0), 7.0, t(B), K), + I::FitToWindow => { + for (cx, cy, sx, sy) in [(2.5, 2.5, 1.0, 1.0), (21.5, 2.5, -1.0, 1.0), (2.5, 21.5, 1.0, -1.0), (21.5, 21.5, -1.0, -1.0)] { + p.rline(&[(cx, cy + 5.0 * sy), (cx, cy), (cx + 5.0 * sx, cy)], 0.8, K); + } + p.rect(7.0, 8.0, 17.0, 16.0, 1.0, t(B), B); + } + I::Grayscale => { + p.rect(3.0, 3.0, 9.0, 21.0, 0.0, K.a(0.85), N); + p.rect(9.0, 3.0, 15.0, 21.0, 0.0, K.a(0.45), N); + p.rect(15.0, 3.0, 21.0, 21.0, 0.0, K.a(0.15), N); + p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + } + I::NewWindow => { + p.rect(2.0, 3.5, 20.0, 17.0, 1.5, N, K); + p.seg((2.0, 7.0), (20.0, 7.0), K); + p.dot((4.5, 5.25), 0.6, K); + p.badge((18.5, 18.0), 4.8, G, Glyph::Plus); + } + I::Macros => { + p.page(3.0, 2.0, 17.0, 21.0, N, K); + p.line(&[(8.5, 9.0), (6.0, 11.5), (8.5, 14.0)], B); + p.line(&[(11.5, 9.0), (14.0, 11.5), (11.5, 14.0)], B); + p.gear_badge((17.5, 17.5), 5.2, O); + } + + // ----- Arrange ----- + I::BringForward => { + p.layer(10.0, 15.5, 8.0, 4.0, N, K); + p.layer(10.0, 8.5, 8.0, 4.0, B, B); + p.arrow((21.0, 13.0), (21.0, 3.0), K); + } + I::SendBackward => { + p.layer(10.0, 15.5, 8.0, 4.0, B, B); + p.layer(10.0, 8.5, 8.0, 4.0, N, K); + p.arrow((21.0, 11.0), (21.0, 21.0), K); + } + I::BringToFront => { + p.layer(10.0, 18.5, 7.5, 3.2, N, K); + p.layer(10.0, 12.0, 7.5, 3.2, N, K); + p.layer(10.0, 5.5, 7.5, 3.2, B, B); + p.arrow((20.5, 14.0), (20.5, 4.5), K); + p.seg((18.0, 2.5), (23.0, 2.5), K); + } + I::SendToBack => { + p.layer(10.0, 18.5, 7.5, 3.2, B, B); + p.layer(10.0, 12.0, 7.5, 3.2, N, K); + p.layer(10.0, 5.5, 7.5, 3.2, N, K); + p.arrow((20.5, 10.0), (20.5, 19.5), K); + p.seg((18.0, 21.5), (23.0, 21.5), K); + } + I::Group => { + p.dashed(&[(2.5, 2.5), (21.5, 2.5), (21.5, 21.5), (2.5, 21.5), (2.5, 2.5)], B, 2.0, 1.6); + p.rect(5.5, 5.5, 13.0, 13.0, 0.8, t(G), G); + p.circle((15.5, 15.5), 4.3, t(O), O); + } + I::Ungroup => { + p.dashed_w(&[(2.0, 2.0), (12.0, 2.0), (12.0, 12.0), (2.0, 12.0), (2.0, 2.0)], B, 1.6, 1.3, 0.8); + p.rect(4.5, 4.5, 9.5, 9.5, 0.6, t(G), G); + p.dashed_w(&[(12.0, 12.0), (22.0, 12.0), (22.0, 22.0), (12.0, 22.0), (12.0, 12.0)], B, 1.6, 1.3, 0.8); + p.circle((17.0, 17.0), 2.9, t(O), O); + } + I::AlignObjects => { + p.dashed(&[(2.0, 12.0), (22.0, 12.0)], K, 2.0, 1.5); + p.rect(3.5, 4.0, 8.0, 20.0, 0.8, B, N); + p.rect(10.0, 7.5, 14.0, 16.5, 0.8, O, N); + p.rect(16.0, 5.5, 20.5, 18.5, 0.8, G, N); + } + I::AlignObjLeft => { + p.rect(3.5, 5.0, 19.5, 10.5, 0.8, t(B), B); + p.rect(3.5, 13.5, 13.5, 19.0, 0.8, t(B), B); + p.seg((3.5, 2.5), (3.5, 21.5), K); + } + I::AlignObjCenter => { + p.rect(4.0, 5.0, 20.0, 10.5, 0.8, t(B), B); + p.rect(7.0, 13.5, 17.0, 19.0, 0.8, t(B), B); + p.seg((12.0, 2.5), (12.0, 21.5), K); + } + I::AlignObjRight => { + p.rect(4.5, 5.0, 20.5, 10.5, 0.8, t(B), B); + p.rect(10.5, 13.5, 20.5, 19.0, 0.8, t(B), B); + p.seg((20.5, 2.5), (20.5, 21.5), K); + } + I::AlignObjTop => { + p.rect(5.0, 3.5, 10.5, 19.5, 0.8, t(B), B); + p.rect(13.5, 3.5, 19.0, 13.5, 0.8, t(B), B); + p.seg((2.5, 3.5), (21.5, 3.5), K); + } + I::AlignObjMiddle => { + p.rect(5.0, 4.0, 10.5, 20.0, 0.8, t(B), B); + p.rect(13.5, 7.0, 19.0, 17.0, 0.8, t(B), B); + p.seg((2.5, 12.0), (21.5, 12.0), K); + } + I::AlignObjBottom => { + p.rect(5.0, 4.5, 10.5, 20.5, 0.8, t(B), B); + p.rect(13.5, 10.5, 19.0, 20.5, 0.8, t(B), B); + p.seg((2.5, 20.5), (21.5, 20.5), K); + } + I::DistributeH => { + p.seg((2.5, 3.0), (2.5, 21.0), K); + p.seg((21.5, 3.0), (21.5, 21.0), K); + p.rect(4.9, 7.0, 8.2, 17.0, 0.6, t(B), B); + p.rect(10.35, 5.0, 13.65, 19.0, 0.6, t(B), B); + p.rect(15.8, 8.5, 19.1, 15.5, 0.6, t(B), B); + } + I::DistributeV => { + p.seg((3.0, 2.5), (21.0, 2.5), K); + p.seg((3.0, 21.5), (21.0, 21.5), K); + p.rect(7.0, 4.9, 17.0, 8.2, 0.6, t(B), B); + p.rect(5.0, 10.35, 19.0, 13.65, 0.6, t(B), B); + p.rect(8.5, 15.8, 15.5, 19.1, 0.6, t(B), B); + } + I::RotateLeft => { + p.poly(&[(3.0, 21.5), (3.0, 13.0), (12.0, 21.5)], t(B), B); + p.arc_arrow((13.0, 12.0), 7.5, 30.0, -160.0, K); + } + I::RotateRight => { + p.poly(&[(21.0, 21.5), (21.0, 13.0), (12.0, 21.5)], t(B), B); + p.arc_arrow((11.0, 12.0), 7.5, 150.0, 340.0, K); + } + I::FlipH => { + p.dashed(&[(12.0, 2.0), (12.0, 22.0)], K, 1.8, 1.4); + p.poly(&[(9.5, 5.0), (9.5, 19.0), (2.5, 19.0)], N, K); + p.poly(&[(14.5, 5.0), (21.5, 19.0), (14.5, 19.0)], B, B); + } + I::FlipV => { + p.dashed(&[(2.0, 12.0), (22.0, 12.0)], K, 1.8, 1.4); + p.poly(&[(5.0, 9.5), (19.0, 9.5), (19.0, 2.5)], N, K); + p.poly(&[(5.0, 14.5), (19.0, 21.5), (19.0, 14.5)], B, B); + } + I::SelectionPane => { + p.rect(3.0, 2.5, 21.0, 21.5, 1.5, N, K); + p.rect(4.6, 4.6, 19.4, 9.4, 0.8, t(B), N); + for (y, c) in [(7.0, B), (12.0, O), (17.0, G)] { + p.rect(6.0, y - 1.3, 8.6, y + 1.3, 0.4, c, N); + p.seg_w((10.5, y), (17.5, y), K, 0.85); + } + } + I::Rotate => { + p.rect(9.0, 9.0, 15.0, 15.0, 0.8, t(B), B); + p.arc_arrow((12.0, 12.0), 8.5, -70.0, 220.0, K); + } + + // ----- Picture ----- + I::Crop => { + p.rline_w(&[(6.5, 2.0), (6.5, 17.5), (22.0, 17.5)], 0.6, K, 1.25); + p.rline_w(&[(2.0, 6.5), (17.5, 6.5), (17.5, 22.0)], 0.6, B, 1.25); + } + I::Corrections => { + p.poly(&arc_pts((12.0, 12.0), 5.2, 5.2, -90.0, 90.0), Y, N); + p.circle((12.0, 12.0), 5.2, N, K); + for i in 0..8 { + let (s, c) = rad(i as f32 * 45.0).sin_cos(); + p.seg((12.0 + c * 7.6, 12.0 + s * 7.6), (12.0 + c * 9.8, 12.0 + s * 9.8), Y); + } + } + I::ColorAdjust => { + p.circle((12.0, 8.0), 5.6, R.a(0.6), N); + p.circle((8.3, 14.6), 5.6, G.a(0.6), N); + p.circle((15.7, 14.6), 5.6, B.a(0.6), N); + } + I::ArtisticEffects => { + p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K); + p.curve_w((5.5, 16.0), (8.0, 4.5), (14.0, 21.0), (18.5, 8.0), PU, 2.0); + } + I::Transparency => { + let cs = 13.0 / 4.0; + for i in 0..4 { + for j in 0..4 { + if (i + j) % 2 == 0 { + let (x, y) = (3.0 + i as f32 * cs, 3.0 + j as f32 * cs); + p.rect(x, y, x + cs, y + cs, 0.0, K.a(0.3), N); + } + } + } + p.rect(3.0, 3.0, 16.0, 16.0, 0.6, N, K.a(0.6)); + p.rect(9.0, 9.0, 21.0, 21.0, 1.2, B.a(0.55), B); + } + I::CompressPictures => { + p.picture(6.5, 6.0, 17.5, 18.0); + p.arrow((0.8, 12.0), (5.0, 12.0), K); + p.arrow((23.2, 12.0), (19.0, 12.0), K); + } + I::ChangePicture => { + p.picture(2.0, 3.0, 18.0, 15.5); + let c = (17.5, 17.5); + if p.dis { + p.circle(c, 5.0, N, G); + } else { + p.circle(c, 5.0, G, N); + } + let wc = if p.dis { K } else { W }; + p.arc_arrow(c, 2.8, 200.0, 330.0, wc); + p.arc_arrow(c, 2.8, 20.0, 150.0, wc); + } + I::ResetPicture => { + p.picture(2.0, 3.0, 18.0, 15.5); + p.arc_arrow((17.0, 17.5), 4.0, -10.0, -260.0, B); + } + I::RemoveBackground => { + p.rect(2.0, 4.0, 22.0, 20.0, 1.5, PU.a(0.55), N); + p.dot((12.0, 10.0), 3.0, B); + p.poly(&arc_pts((12.0, 19.3), 6.0, 5.5, 180.0, 360.0), B, N); + p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K); + } + I::AltText => { + p.picture(2.0, 2.0, 15.5, 12.5); + p.seg((2.5, 16.5), (21.5, 16.5), K); + p.seg((2.5, 20.5), (15.0, 20.5), K); + } + + // ----- Table ----- + I::InsertAbove | I::InsertBelow | I::InsertLeft | I::InsertRight => { + let (grid, add): ((f32, f32, f32, f32, usize, usize), (f32, f32, f32, f32)) = match icon { + I::InsertAbove => ((3.0, 10.0, 21.0, 21.0, 2, 3), (3.0, 2.5, 21.0, 7.5)), + I::InsertBelow => ((3.0, 3.0, 21.0, 14.0, 2, 3), (3.0, 16.5, 21.0, 21.5)), + I::InsertLeft => ((10.0, 3.0, 21.0, 21.0, 3, 2), (2.5, 3.0, 7.5, 21.0)), + _ => ((3.0, 3.0, 14.0, 21.0, 3, 2), (16.5, 3.0, 21.5, 21.0)), + }; + p.table(grid.0, grid.1, grid.2, grid.3, grid.4, grid.5, N); + p.rect(add.0, add.1, add.2, add.3, 1.0, t(G), G); + let c = ((add.0 + add.2) * 0.5, (add.1 + add.3) * 0.5); + p.seg_w((c.0 - 1.6, c.1), (c.0 + 1.6, c.1), G, 0.9); + p.seg_w((c.0, c.1 - 1.6), (c.0, c.1 + 1.6), G, 0.9); + } + I::DeleteTable => { + p.table(2.5, 3.0, 19.5, 17.0, 3, 3, B); + p.badge((18.0, 18.0), 5.0, R, Glyph::Cross); + } + I::MergeCells => { + p.rect(2.5, 6.0, 21.5, 18.0, 1.2, N, K); + p.dashed(&[(12.0, 6.0), (12.0, 18.0)], K.a(0.6), 1.5, 1.2); + p.arrow((4.5, 12.0), (10.2, 12.0), B); + p.arrow((19.5, 12.0), (13.8, 12.0), B); + } + I::SplitCells => { + p.rect(2.5, 6.0, 21.5, 18.0, 1.2, N, K); + p.seg((12.0, 6.0), (12.0, 18.0), K); + p.arrow((10.0, 12.0), (4.5, 12.0), B); + p.arrow((14.0, 12.0), (19.5, 12.0), B); + } + I::Borders => { + p.dotted(&[(12.0, 5.0), (12.0, 19.0)], K, 2.2, 0.45); + p.dotted(&[(5.0, 12.0), (19.0, 12.0)], K, 2.2, 0.45); + p.rect(3.0, 3.0, 21.0, 21.0, 0.6, N, B); + } + I::Shading => { + p.rect(3.0, 3.0, 12.0, 12.0, 0.0, Y.a(0.8), N); + p.rect(12.0, 12.0, 21.0, 21.0, 0.0, Y.a(0.8), N); + p.table(3.0, 3.0, 21.0, 21.0, 2, 2, N); + } + + // ----- Chart ----- + I::ChartElements => { + p.bars(2.5, 3.5, 16.0, 17.5); + p.badge((18.5, 18.5), 4.8, G, Glyph::Plus); + } + I::ChartStyles => { + p.bars(2.5, 6.0, 15.5, 20.5); + p.brush((14.0, 10.0), -45.0, 12.0, PU); + } + I::SwitchRowCol => { + p.table(2.5, 2.5, 11.0, 11.0, 2, 2, B); + p.rect(13.0, 13.0, 17.25, 21.5, 0.6, O, N); + p.table(13.0, 13.0, 21.5, 21.5, 2, 2, N); + p.line(&quad_pts((14.0, 4.0), (20.0, 4.0), (20.0, 9.5), 10), K); + p.head((20.0, 11.0), 90.0, 3.6, K); + p.line(&quad_pts((10.0, 20.0), (4.0, 20.0), (4.0, 14.5), 10), K); + p.head((4.0, 13.0), -90.0, 3.6, K); + } + I::EditData => { + p.table(2.5, 3.0, 18.0, 16.0, 3, 3, B); + p.tool((11.0, 21.5), -45.0, 14.0, 2.2, 3.8, Y, K, 2.5, R); + } + I::ChangeChartType => { + p.circle((8.0, 8.0), 5.8, t(B), B); + let mut wedge = vec![(8.0, 8.0)]; + wedge.extend(arc_pts((8.0, 8.0), 5.8, 5.8, -90.0, 0.0)); + p.poly(&wedge, O, N); + p.rect(13.0, 15.0, 15.8, 21.0, 0.4, G, N); + p.rect(17.5, 11.0, 20.3, 21.0, 0.4, B, N); + p.seg((11.5, 21.5), (22.0, 21.5), K); + } + + // ----- Media ----- + I::Play => p.rshape(&[(6.5, 4.0), (19.5, 12.0), (6.5, 20.0)], 1.0, G, N), + I::Pause => { + p.rect(6.0, 4.5, 10.0, 19.5, 1.0, K, N); + p.rect(14.0, 4.5, 18.0, 19.5, 1.0, K, N); + } + I::Stop => p.rect(5.0, 5.0, 19.0, 19.0, 2.0, K, N), + I::TrimMedia => { + p.rect(2.0, 8.0, 22.0, 16.0, 1.2, N, K); + p.rect(7.0, 8.0, 17.0, 16.0, 0.0, t(Y), N); + p.rect(5.8, 5.0, 8.2, 19.0, 0.8, Y, N); + p.rect(15.8, 5.0, 18.2, 19.0, 0.8, Y, N); + } + I::Volume => { + p.speaker(2.5, 12.0, N, K); + p.arc((11.0, 12.0), 3.8, -45.0, 45.0, B); + p.arc((11.0, 12.0), 7.0, -45.0, 45.0, B); + p.arc((11.0, 12.0), 10.2, -40.0, 40.0, B); + } + I::Loop => { + p.path_arrow(&round_pts(&[(4.0, 13.0), (4.0, 7.0), (20.0, 7.0)], 2.5, false), G); + p.path_arrow(&round_pts(&[(20.0, 11.0), (20.0, 17.0), (4.0, 17.0)], 2.5, false), G); + } + I::Bookmark => { + p.rshape(&[(6.0, 2.5), (18.0, 2.5), (18.0, 21.5), (12.0, 16.5), (6.0, 21.5)], 0.8, R, N); + } + + // ----- Window chrome ----- + I::Home => { + p.rect(10.0, 14.5, 14.0, 21.0, 0.8, B, N); + p.line(&[(2.5, 11.5), (12.0, 3.0), (21.5, 11.5)], K); + p.rline(&[(5.0, 9.5), (5.0, 21.0), (19.0, 21.0), (19.0, 9.5)], 1.2, K); + } + I::Save => { + p.rshape(&[(3.0, 3.0), (17.5, 3.0), (21.0, 6.5), (21.0, 21.0), (3.0, 21.0)], 1.5, t(B), B); + p.rect(7.0, 3.0, 15.0, 8.5, 0.6, N, B); + p.seg((12.5, 4.5), (12.5, 7.0), B); + p.rect(6.5, 13.0, 17.5, 21.0, 0.8, N, B); + } + I::Undo | I::Redo => { + let mut pts = vec![(6.0, 8.5), (14.0, 8.5)]; + pts.extend(arc_pts((14.0, 14.0), 5.5, 5.5, -90.0, 90.0)); + pts.push((8.5, 19.5)); + let hs = p.head_size(); + let pts: Vec = if icon == I::Undo { pts } else { pts.into_iter().map(|(x, y)| (24.0 - x, y)).collect() }; + p.line(&pts, K); + if icon == I::Undo { + p.head((3.0, 8.5), 180.0, hs, K); + } else { + p.head((21.0, 8.5), 0.0, hs, K); + } + } + I::More => { + for x in [5.0, 12.0, 19.0] { + p.dot((x, 12.0), 1.9, K); + } + } + I::Search => p.magnifier((10.0, 10.0), 6.5, N, K), + I::Share => { + p.seg((6.0, 12.0), (18.0, 5.5), K); + p.seg((6.0, 12.0), (18.0, 18.5), K); + p.dot((18.0, 5.5), 3.0, B); + p.dot((6.0, 12.0), 3.0, B); + p.dot((18.0, 18.5), 3.0, B); + } + I::CommentsBubble => p.bubble(2.5, 3.5, 21.5, 17.0, N, K), + I::File => { + p.page(4.5, 2.0, 19.5, 22.0, N, K); + p.seg((8.0, 12.5), (16.0, 12.5), B); + p.seg((8.0, 16.5), (14.0, 16.5), B); + } + I::Folder => { + p.rshape(&[(2.5, 4.5), (9.0, 4.5), (11.0, 7.0), (21.5, 7.0), (21.5, 19.5), (2.5, 19.5)], 1.2, t(Y), K); + p.rshape(&[(2.5, 10.0), (21.5, 10.0), (21.5, 19.5), (2.5, 19.5)], 1.2, Y.a(0.6), K); + } + I::Export => { + p.rline(&[(15.0, 8.5), (15.0, 2.5), (3.0, 2.5), (3.0, 21.5), (15.0, 21.5), (15.0, 15.5)], 1.5, K); + p.arrow((8.5, 12.0), (22.0, 12.0), B); + } + I::Print => { + p.rect(6.5, 2.5, 17.5, 9.0, 0.8, N, K); + p.rline(&[(6.5, 17.5), (2.5, 17.5), (2.5, 9.0), (21.5, 9.0), (21.5, 17.5), (17.5, 17.5)], 1.8, K); + p.rect(6.5, 14.0, 17.5, 21.5, 0.8, N, K); + p.dot((18.5, 12.0), 1.1, G); + } + I::Settings => { + p.gear((12.0, 12.0), 9.8, 7.3, 8, N, K); + p.circle((12.0, 12.0), 3.0, N, K); + } + I::Help => { + p.circle((12.0, 12.0), 9.8, N, K); + let mut q = arc_pts((12.0, 9.4), 3.2, 3.0, 180.0, 400.0); + q.push((12.0, 13.0)); + q.push((12.0, 14.6)); + p.line(&q, B); + p.dot((12.0, 17.8), 1.25, B); + } + I::Info => { + p.circle((12.0, 12.0), 9.8, N, K); + p.dot((12.0, 7.3), 1.3, B); + p.seg((12.0, 10.8), (12.0, 17.5), B); + } + I::Warning => { + p.rshape(&[(12.0, 2.5), (22.5, 20.5), (1.5, 20.5)], 2.0, Y, N); + p.seg((12.0, 9.0), (12.0, 14.0), D); + p.dot((12.0, 17.2), 1.2, D); + } + I::Lock => { + let mut sh = vec![(7.5, 10.5), (7.5, 8.0)]; + sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0)); + sh.push((16.5, 10.5)); + p.line(&sh, K); + p.rect(4.5, 10.5, 19.5, 21.5, 1.8, Y, K); + p.dot((12.0, 15.0), 1.5, D); + p.seg((12.0, 15.5), (12.0, 18.3), D); + } + I::Eye | I::EyeOff => { + let mut e = quad_pts((2.0, 12.0), (12.0, 2.5), (22.0, 12.0), 16); + e.extend(quad_pts((22.0, 12.0), (12.0, 21.5), (2.0, 12.0), 16).into_iter().skip(1)); + e.pop(); + p.shape(&e, N, K); + p.circle((12.0, 12.0), 3.4, B, N); + p.dot((12.0, 12.0), 1.3, if p.dis { N } else { W }); + if icon == I::EyeOff { + p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1); + } + } + I::Close => { + p.seg_w((5.5, 5.5), (18.5, 18.5), K, 1.1); + p.seg_w((18.5, 5.5), (5.5, 18.5), K, 1.1); + } + I::Check => p.line_w(&[(4.0, 12.5), (9.5, 18.0), (20.0, 6.5)], G, 1.3), + I::Plus => { + p.seg((12.0, 4.5), (12.0, 19.5), K); + p.seg((4.5, 12.0), (19.5, 12.0), K); + } + I::Minus => p.seg((4.5, 12.0), (19.5, 12.0), K), + I::ChevronDown => p.line(&[(6.0, 9.0), (12.0, 15.0), (18.0, 9.0)], K), + I::ChevronUp => p.line(&[(6.0, 15.0), (12.0, 9.0), (18.0, 15.0)], K), + I::ChevronLeft => p.line(&[(15.0, 6.0), (9.0, 12.0), (15.0, 18.0)], K), + I::ChevronRight => p.line(&[(9.0, 6.0), (15.0, 12.0), (9.0, 18.0)], K), + I::DragHandle => { + for x in [9.0, 15.0] { + for y in [6.0, 12.0, 18.0] { + p.dot((x, y), 1.6, K); + } + } + } + I::Star => p.star((12.0, 12.8), 10.0, t(Y), Y), + I::Image => { + p.rect(2.5, 4.0, 21.5, 20.0, 1.5, N, K); + p.circle((16.0, 9.0), 1.8, N, K); + p.line(&[(2.5, 17.5), (8.5, 11.0), (14.0, 16.5), (16.5, 14.0), (21.5, 18.5)], K); + } + I::Sparkle => { + p.sparkle((10.5, 13.0), 9.0, PU); + p.sparkle((19.0, 5.0), 3.8, Y); + } + I::Palette => { + let mut pts = arc_pts((12.0, 12.0), 9.8, 9.5, 70.0, 380.0); + pts.extend(cubic_pts((21.2, 15.25), (19.0, 15.0), (14.5, 14.0), (15.5, 21.0), 10)); + p.shape(&pts, N, K); + p.dot((7.5, 9.5), 1.7, R); + p.dot((11.5, 6.3), 1.7, Y); + p.dot((16.3, 7.8), 1.7, G); + p.dot((7.2, 14.8), 1.7, B); + } + I::Grid => { + for (x, y) in [(3.0, 3.0), (13.0, 3.0), (3.0, 13.0), (13.0, 13.0)] { + p.rect(x, y, x + 8.0, y + 8.0, 1.5, N, K); + } + } + I::List => { + for y in [5.5, 12.0, 18.5] { + p.dot((4.5, y), 1.5, K); + p.seg((9.0, y), (21.0, y), K); + } + } + I::Collapse => { + p.line(&[(7.0, 3.0), (12.0, 8.0), (17.0, 3.0)], K); + p.line(&[(7.0, 21.0), (12.0, 16.0), (17.0, 21.0)], K); + p.seg_w((5.0, 12.0), (19.0, 12.0), K.a(0.5), 0.8); + } + I::Expand => { + p.line(&[(7.0, 8.0), (12.0, 3.0), (17.0, 8.0)], K); + p.line(&[(7.0, 16.0), (12.0, 21.0), (17.0, 16.0)], K); + p.seg_w((5.0, 12.0), (19.0, 12.0), K.a(0.5), 0.8); + } + I::Pin => { + let m = |pts: &[P2]| tf((12.0, 12.0), 35.0, pts); + p.line(&m(&[(0.0, 2.0), (0.0, 10.5)]), K); + p.rshape(&m(&[(-6.0, 0.0), (6.0, 0.0), (6.0, 2.5), (-6.0, 2.5)]), 0.6, R, N); + p.poly(&m(&[(-3.0, -7.0), (3.0, -7.0), (3.6, 0.0), (-3.6, 0.0)]), R, N); + p.rshape(&m(&[(-4.2, -10.0), (4.2, -10.0), (4.2, -7.0), (-4.2, -7.0)]), 0.6, R, N); + } + + // ----- Status bar (monochrome) ----- + I::NotesSmall => { + p.rect(3.0, 4.0, 21.0, 20.0, 1.5, N, K); + p.seg((3.0, 13.5), (21.0, 13.5), K); + p.seg((6.5, 16.8), (15.5, 16.8), K); + } + I::CommentsSmall => p.bubble(3.0, 4.0, 21.0, 16.5, N, K), + I::ViewNormalSmall => { + p.rect(2.5, 4.0, 21.5, 20.0, 1.5, N, K); + p.seg((8.5, 4.0), (8.5, 20.0), K); + } + I::ViewSorterSmall => { + for (x, y) in [(2.5, 4.5), (13.0, 4.5), (2.5, 13.5), (13.0, 13.5)] { + p.rect(x, y, x + 8.5, y + 6.0, 1.0, N, K); + } + } + I::ViewReadingSmall => { + p.book(N, K); + p.seg((12.0, 7.0), (12.0, 20.5), K); + } + I::ViewShowSmall => { + p.seg((2.0, 4.0), (22.0, 4.0), K); + p.rect(3.5, 4.0, 20.5, 16.0, 0.8, N, K); + p.seg((12.0, 16.0), (12.0, 19.0), K); + p.line(&[(8.5, 22.0), (12.0, 19.0), (15.5, 22.0)], K); + } + I::FitSmall => { + for (cx, cy, sx, sy) in [(3.0, 3.0, 1.0, 1.0), (21.0, 3.0, -1.0, 1.0), (3.0, 21.0, 1.0, -1.0), (21.0, 21.0, -1.0, -1.0)] { + p.rline(&[(cx, cy + 5.0 * sy), (cx, cy), (cx + 5.0 * sx, cy)], 0.8, K); + } + p.rect(8.0, 8.5, 16.0, 15.5, 0.8, N, K); + } + I::ZoomIn | I::ZoomOut => { + p.magnifier((10.0, 10.0), 7.0, N, K); + p.seg((7.0, 10.0), (13.0, 10.0), K); + if icon == I::ZoomIn { + p.seg((10.0, 7.0), (10.0, 13.0), K); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn names_round_trip_and_are_unique() { + let mut seen = HashSet::new(); + let mut names = HashSet::new(); + for &icon in Icon::ALL { + assert!(seen.insert(icon), "duplicate icon {icon:?} in ALL"); + let name = icon.name(); + assert!(names.insert(name), "duplicate name {name}"); + assert_eq!(Icon::from_name(name), Some(icon)); + assert!(name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), "{name} is not kebab-case"); + } + assert_eq!(Icon::from_name("no-such-icon"), None); + } + + #[test] + fn stroke_width_scales() { + assert!((stroke_width(16.0) - 1.0).abs() < 1e-6); + assert!((stroke_width(32.0) - 1.6).abs() < 1e-6); + assert!((stroke_width(8.0) - 1.0).abs() < 1e-6); + } + + #[test] + fn triangulates_concave_polygon() { + let pts: Vec = star_pts((0.0, 0.0), 10.0, 4.0, 5, 0.0).into_iter().map(|(x, y)| pos2(x, y)).collect(); + assert_eq!(triangulate(&pts).len(), pts.len() - 2); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs new file mode 100644 index 0000000..41953e4 --- /dev/null +++ b/crates/ui-egui/src/lib.rs @@ -0,0 +1,754 @@ +//! SlideCraft's egui frontend: a PowerPoint-style UI over `slidecraft-engine`. +//! +//! The UI is thin: every action goes through [`SlideApp::run`], which handles UI commands +//! (views, panes, zoom) here and sends everything else to the engine. The ribbon, menus, +//! shortcuts, the command palette and the control channel all share that entry point. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod canvas; +pub mod control; +pub mod dialogs; +pub mod icons; +pub mod menus; +pub mod panes; +pub mod ribbon; +pub mod show; +pub mod sorter; +pub mod status; +pub mod textures; +pub mod theme; +pub mod thumbs; +pub mod widgets; + +use std::sync::mpsc::{Receiver, Sender}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use slidecraft_engine::{Mods, Session, UiRequest}; + +pub use control::{ControlRequest, ControlResponse}; + +pub type ReadFn = Box Result, String>>; +pub type WriteFn = Box Result<(), String>>; +pub type PickFn = Box Option>; +pub type OpenAsyncFn = Box; +pub type DownloadFn = Box; +/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files). +pub type Inbox = std::sync::Arc)>>>; + +/// Platform services injected by the host (desktop or web). +#[derive(Default)] +pub struct Services { + /// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path. + pub pick_open: Option, + /// Save dialog with a suggested name → path. + pub pick_save: Option, + pub read: Option, + pub write: Option, + /// Asynchronous open dialog (web): the chosen file arrives through `inbox`. + pub open_async: Option, + /// Hand bytes to the user as a named file (browser download). + pub download: Option, + pub inbox: Option, + /// Image on the system clipboard (PNG bytes), when the host can read one. + pub clipboard_image: Option Option>>>, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ViewMode { + #[default] + Normal, + Outline, + Sorter, + NotesPage, + Reading, +} + +/// Persisted UI state. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct UiState { + pub brightness: theme::Brightness, + pub tab: String, + pub ribbon_collapsed: bool, + pub view: ViewMode, + pub notes: bool, + pub notes_height: f32, + pub thumbs_width: f32, + /// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`. + pub pane: Option, + pub pane_width: f32, + pub ruler: bool, + pub gridlines: bool, + pub guides: bool, + pub smart_guides: bool, + /// None = fit to window. + pub zoom: Option, + pub sorter_zoom: f32, + pub recent: Vec, + pub presenter_view: bool, + pub ui_scale: f32, + /// Format Shape pane: tab (`fill`, `effects`, `size`, `picture`, `text`). + pub format_tab: String, + pub grayscale: bool, +} + +impl Default for UiState { + fn default() -> Self { + UiState { + brightness: theme::Brightness::Light, + tab: "Home".into(), + ribbon_collapsed: false, + view: ViewMode::Normal, + notes: true, + notes_height: 52.0, + thumbs_width: 236.0, + pane: None, + pane_width: 300.0, + ruler: false, + gridlines: false, + guides: false, + smart_guides: true, + zoom: None, + sorter_zoom: 1.0, + recent: vec![], + presenter_view: true, + ui_scale: 1.0, + format_tab: "fill".into(), + grayscale: false, + } + } +} + +/// UI-only commands (id, label, shortcut, params). +pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ + ("view.normal", "Normal", None, "{}"), + ("view.outline", "Outline View", None, "{}"), + ("view.sorter", "Slide Sorter", None, "{}"), + ("view.notesPage", "Notes Page", None, "{}"), + ("view.reading", "Reading View", None, "{}"), + ("view.notes", "Notes", None, "{on?: bool}"), + ("view.ruler", "Ruler", None, "{on?: bool}"), + ("view.gridlines", "Gridlines", Some("Shift+F9"), "{on?: bool}"), + ("view.guides", "Guides", Some("Alt+F9"), "{on?: bool}"), + ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), + ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), + ("view.zoom", "Zoom…", None, "{percent}"), + ("view.fit", "Fit to Window", None, "{}"), + ("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|null}"), + ("view.tab", "Ribbon Tab", None, "{tab}"), + ("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"), + ("view.dark", "Dark Mode", None, "{on?: bool}"), + ("view.grayscale", "Grayscale", None, "{on?: bool}"), + ("show.start", "Slide Show", Some("F5"), "{from?: index}"), + ("show.presenter", "Presenter View", None, "{on?: bool}"), + ("show.end", "End Show", None, "{}"), + ("app.about", "About SlideCraft", None, "{}"), + ("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"), + ("app.preferences", "Preferences…", Some("Cmd+,"), "{}"), + ("app.openDialog", "Open…", None, "{purpose?}"), + ("app.insertPictureDialog", "Picture from File…", None, "{}"), + ("app.insertAudioDialog", "Audio from File…", None, "{}"), + ("app.insertVideoDialog", "Video from File…", None, "{}"), + ("app.saveAsDialog", "Save As…", None, "{}"), + ("app.exportDialog", "Export…", None, "{}"), + ("app.dialog", "Open Dialog", None, "{id}"), + ("app.links", "Community Links", None, "{}"), +]; + +pub struct Perf { + pub frame_ms: f64, + pub render_ms: f64, + pub fps: f64, +} + +pub struct SlideApp { + pub session: Session, + pub services: Services, + pub ui: UiState, + pub textures: textures::Textures, + pub control_rx: Option>, + pub dialog: Option, + pub show: Option, + pub status: Option<(String, f64)>, + pub perf: Perf, + /// Synthetic input events (control channel `ui.click` etc.), injected one per frame. + pub synthetic: Vec, + /// Canvas screen rect and points-per-slide-point scale (for control & tests). + pub canvas_rect: Option, + pub canvas_scale: f32, + pub slide_rect: Option, + pub palette: Option<(String, usize)>, + /// Integrated title bar (macOS traffic lights over our chrome). + pub integrated_titlebar: bool, + pending_urls: Vec, + shot_token: u64, + queued_shots: Vec<(u64, f64, u32)>, + pending_shots: Vec<(u64, Option, Sender, f64)>, + styled: bool, + restyle: bool, + fonts_ready: bool, + last_time: f64, + pub(crate) notes_buf: (u64, usize, String), + /// Thumbnail pane drag (slide index, current drop index). + pub(crate) thumb_drag: Option<(usize, usize)>, +} + +impl SlideApp { + pub fn new(session: Session, services: Services) -> Self { + SlideApp { + session, + services, + ui: UiState::default(), + textures: textures::Textures::default(), + control_rx: None, + dialog: None, + show: None, + status: None, + perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 }, + synthetic: vec![], + canvas_rect: None, + canvas_scale: 1.0, + slide_rect: None, + palette: None, + integrated_titlebar: false, + pending_urls: vec![], + shot_token: 0, + queued_shots: vec![], + pending_shots: vec![], + styled: false, + restyle: true, + fonts_ready: false, + last_time: 0.0, + notes_buf: (0, usize::MAX, String::new()), + thumb_drag: None, + } + } + + pub fn with_control(mut self, rx: Receiver) -> Self { + self.control_rx = Some(rx); + self + } + + pub fn set_status(&mut self, msg: impl Into) { + self.status = Some((msg.into(), now_ms())); + } + + /// Run a command by id (UI commands here, everything else in the engine). Errors show in the + /// status bar and are returned. + pub fn run(&mut self, id: &str, params: Value) -> Result { + let r = if UI_COMMANDS.iter().any(|c| c.0 == id) { + self.run_ui(id, ¶ms) + } else { + self.session.execute(id, ¶ms).map_err(|e| e.to_string()) + }; + if let Err(e) = &r { + self.set_status(e.clone()); + } + self.drain_requests(); + r + } + + fn flag(&mut self, params: &Value, cur: bool) -> bool { + params.get("on").and_then(Value::as_bool).unwrap_or(!cur) + } + + fn run_ui(&mut self, id: &str, p: &Value) -> Result { + match id { + "view.normal" => self.ui.view = ViewMode::Normal, + "view.outline" => self.ui.view = ViewMode::Outline, + "view.sorter" => self.ui.view = ViewMode::Sorter, + "view.notesPage" => self.ui.view = ViewMode::NotesPage, + "view.reading" => self.start_show(self.session.active().map(|d| d.selection.slide).unwrap_or(0), true), + "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), + "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), + "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), + "view.guides" => self.ui.guides = self.flag(p, self.ui.guides), + "view.zoomIn" | "view.zoomOut" => { + let cur = self.ui.zoom.unwrap_or(self.canvas_scale); + let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; + let next = if id == "view.zoomIn" { + steps.iter().copied().find(|s| *s > cur + 0.01).unwrap_or(4.0) + } else { + steps.iter().rev().copied().find(|s| *s < cur - 0.01).unwrap_or(0.1) + }; + self.ui.zoom = Some(next); + } + "view.zoom" => { + let pct = p.get("percent").and_then(Value::as_f64).ok_or("missing `percent`")?; + self.ui.zoom = Some((pct as f32 / 100.0).clamp(0.1, 4.0)); + } + "view.fit" => self.ui.zoom = None, + "view.pane" => { + let pane = p.get("pane").and_then(Value::as_str).map(String::from); + self.ui.pane = + if pane.is_some() && pane == self.ui.pane && p.get("toggle").and_then(Value::as_bool).unwrap_or(false) { None } else { pane }; + if let Some(tab) = p.get("tab").and_then(Value::as_str) { + self.ui.format_tab = tab.to_string(); + } + } + "view.tab" => { + let tab = p.get("tab").and_then(Value::as_str).ok_or("missing `tab`")?; + self.ui.tab = tab.to_string(); + self.ui.ribbon_collapsed = false; + } + "view.collapseRibbon" => self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed, + "view.dark" => { + let on = self.flag(p, self.ui.brightness == theme::Brightness::Dark); + self.ui.brightness = if on { theme::Brightness::Dark } else { theme::Brightness::Light }; + self.restyle = true; + } + "view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale), + "show.start" => { + let from = p.get("from").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(0); + self.start_show(from, false); + } + "show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view), + "show.end" => self.show = None, + "app.about" => self.dialog = Some(dialogs::Dialog::new("about")), + "app.palette" => self.palette = Some((String::new(), 0)), + "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), + "app.openDialog" => self.pick_and_open(), + "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), + "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), + "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), + "app.saveAsDialog" => self.save_as_dialog("slidecraft"), + "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.dialog" => { + let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; + self.dialog = Some(dialogs::Dialog::new(d)); + } + "app.links" => { + use slidecraft_engine::links::*; + return Ok(json!({"discord": DISCORD, "website": WEBSITE, "apps": APPS, "appPage": APP_PAGE, "github": GITHUB})); + } + _ => return Err(format!("unknown UI command `{id}`")), + } + Ok(Value::Null) + } + + pub fn start_show(&mut self, from: usize, reading: bool) { + if let Some(d) = self.session.active() { + self.show = Some(show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading)); + } + } + + fn pick_and_open(&mut self) { + if let Some(pick) = self.services.pick_open.as_mut() { + if let Some(path) = pick("open") { + let _ = self.open_path(&path); + } + } else if let Some(f) = self.services.open_async.as_mut() { + f("open"); + } + } + + pub fn open_path(&mut self, path: &str) -> Result<(), String> { + self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; + self.ui.recent.retain(|p| p != path); + self.ui.recent.insert(0, path.to_string()); + self.ui.recent.truncate(12); + self.ui.view = ViewMode::Normal; + Ok(()) + } + + fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { + if let Some(pick) = self.services.pick_open.as_mut() { + if let Some(path) = pick(purpose) { + let r = match self.services.read.as_ref().map(|r| r(&path)) { + Some(Ok(bytes)) => { + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + self.session.execute(cmd, &json!({"name": name, "data": slidecraft_engine::cmd::base64_encode(&bytes)})) + } + _ => self.session.execute(cmd, &json!({"path": path})), + }; + if let Err(e) = r { + self.set_status(e.to_string()); + } + } + } else if let Some(f) = self.services.open_async.as_mut() { + f(purpose); + } + } + + pub fn save_as_dialog(&mut self, format: &str) { + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + let ext = if format == "pptx" { "pptx" } else { "slidecraft" }; + let suggested = format!("{name}.{ext}"); + if let Some(pick) = self.services.pick_save.as_mut() { + if let Some(path) = pick(&suggested) { + match self.session.execute("file.saveAs", &json!({"path": path})) { + Ok(_) => { + self.set_status(format!("Saved {path}")); + self.ui.recent.retain(|p| *p != path); + self.ui.recent.insert(0, path); + } + Err(e) => self.set_status(e.to_string()), + } + } + } else if let Some(dl) = self.services.download.as_mut() + && let Some(d) = self.session.active() + { + match slidecraft_engine::cmd::file::save_bytes(&d.doc, ext) { + Ok(bytes) => dl(&suggested, &bytes), + Err(e) => self.set_status(e.to_string()), + } + } + } + + pub fn save(&mut self) { + if self.session.active().and_then(|d| d.path.clone()).is_some() { + match self.session.execute("file.save", &json!({})) { + Ok(_) => self.set_status("Saved"), + Err(e) => self.set_status(e.to_string()), + } + } else { + self.save_as_dialog("slidecraft"); + } + } + + /// Act on requests commands left for the UI. + fn drain_requests(&mut self) { + for r in std::mem::take(&mut self.session.ui_requests) { + match r { + UiRequest::Dialog { id, params } => { + let mut d = dialogs::Dialog::new(&id); + d.params = params; + self.dialog = Some(d); + } + UiRequest::Pane { id } => self.ui.pane = Some(id), + UiRequest::StartShow { from } => self.start_show(from, false), + UiRequest::Message { text } => self.set_status(text), + UiRequest::PickFile { purpose } => { + if purpose == "saveAs" { + self.save_as_dialog("slidecraft"); + } + } + } + } + } + + fn drain_inbox(&mut self) { + let Some(inbox) = self.services.inbox.clone() else { return }; + let files: Vec<(String, Vec)> = inbox.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default(); + for (name, bytes) in files { + self.open_or_insert_bytes(&name, &bytes); + } + } + + /// A dropped or picked file: presentations open, media is inserted. + pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) { + let lower = name.to_ascii_lowercase(); + let data = slidecraft_engine::cmd::base64_encode(bytes); + let ct = slidecraft_engine::cmd::insert::content_type(name, bytes); + let cmd = if lower.ends_with(".slidecraft") || lower.ends_with(".pptx") || lower.ends_with(".potx") || lower.ends_with(".ppsx") { + "file.openBytes" + } else if ct.starts_with("image/") { + "insert.picture" + } else if ct.starts_with("audio/") { + "insert.audio" + } else if ct.starts_with("video/") { + "insert.video" + } else if lower.ends_with(".txt") || lower.ends_with(".md") { + "slide.fromOutline" + } else { + self.set_status(format!("{name}: SlideCraft can't open this kind of file")); + return; + }; + let p = if cmd == "slide.fromOutline" { json!({"text": String::from_utf8_lossy(bytes)}) } else { json!({"name": name, "data": data}) }; + if self.session.active().is_none() && cmd != "file.openBytes" { + let _ = self.session.execute("file.new", &json!({})); + } + if let Err(e) = self.session.execute(cmd, &p) { + self.set_status(e.to_string()); + } + } + + /// Per-frame logic before layout: styles, control requests, screenshots, shortcuts. + pub fn logic(&mut self, ctx: &egui::Context) { + let scale = self.ui.ui_scale.clamp(0.5, 3.0); + if (ctx.zoom_factor() - scale).abs() > 1e-3 { + ctx.set_zoom_factor(scale); + } + if !self.styled { + theme::install_fonts(ctx); + self.styled = true; + self.restyle = true; + } else { + self.fonts_ready = true; + } + if self.restyle { + theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); + self.restyle = false; + } + let now = ctx.input(|i| i.time); + let dt = now - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = now; + self.drain_control(ctx); + if self.fonts_ready { + self.drain_inbox(); + } + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } + self.collect_screenshots(ctx); + self.issue_screenshots(ctx); + for f in ctx.input(|i| i.raw.dropped_files.clone()) { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); + match f.bytes() { + Ok(bytes) => self.open_or_insert_bytes(&name, &bytes), + Err(e) => self.set_status(e), + } + } + } + + /// Inject synthetic events (one pointer event per frame). + pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + if self.synthetic.is_empty() { + return; + } + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + raw.events.push(egui::Event::PointerMoved(*p)); + } + let n = n.min(self.synthetic.len()); + raw.events.extend(self.synthetic.drain(..n)); + } + + /// Keyboard and clipboard for the slide (when no text field has focus). + fn keyboard(&mut self, ctx: &egui::Context) { + if ctx.memory(|m| m.focused().is_some()) || self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some() { + return; + } + let events = ctx.input(|i| i.events.clone()); + for e in events { + match e { + egui::Event::Text(t) => { + if self.session.active().is_some_and(|d| d.selection.text.is_some() || d.selection.shapes.len() == 1) { + if let Err(e) = self.session.type_text(&t) { + self.set_status(e.to_string()); + } + } + } + egui::Event::Copy => { + if let Ok(v) = self.session.execute("edit.copy", &json!({})) + && let Some(t) = v.get("text").and_then(Value::as_str) + { + ctx.copy_text(t.to_string()); + } + } + egui::Event::Cut => { + if let Ok(v) = self.session.execute("edit.cut", &json!({})) + && let Some(t) = v.get("text").and_then(Value::as_str) + { + ctx.copy_text(t.to_string()); + } + } + egui::Event::Paste(text) => { + // An image on the clipboard (screenshots, copied pictures) becomes a picture. + let img = if text.is_empty() { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + let r = match img { + Some(png) => self + .session + .execute("insert.picture", &json!({"name": "Pasted image.png", "data": slidecraft_engine::cmd::base64_encode(&png)})), + None => self.session.execute("edit.paste", &json!({"text": text})), + }; + if let Err(e) = r { + self.set_status(e.to_string()); + } + } + egui::Event::Key { key, pressed: true, modifiers, .. } => { + let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; + if menus::ui_shortcut(self, key, mods) { + continue; + } + let name = key_name(key); + match self.session.key(name, mods) { + Ok(_) => {} + Err(e) => self.set_status(e.to_string()), + } + } + _ => {} + } + } + self.drain_requests(); + } + + /// Lay out the whole window. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + if !self.fonts_ready { + ctx.request_repaint(); + return; + } + let t0 = now_ms(); + let t = theme::Tokens::get(&ctx); + if self.show.is_some() { + show::ui(self, ui); + self.perf.frame_ms = now_ms() - t0; + return; + } + self.keyboard(&ctx); + ribbon::title_bar(self, ui); + if self.session.active().is_some() { + ribbon::show(self, ui); + status::status_bar(self, ui); + } + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { + if self.session.active().is_none() { + dialogs::start_screen(self, ui); + return; + } + match self.ui.view { + ViewMode::Sorter => sorter::show(self, ui), + ViewMode::NotesPage => sorter::notes_page(self, ui), + ViewMode::Outline | ViewMode::Normal | ViewMode::Reading => { + if self.ui.pane.is_some() { + panes::show(self, ui); + } + if self.ui.view == ViewMode::Outline { + sorter::outline_pane(self, ui); + } else { + thumbs::show(self, ui); + } + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| { + if self.ui.notes { + status::notes_pane(self, ui); + } + canvas::show(self, ui); + }); + } + } + }); + dialogs::show(self, &ctx); + menus::palette(self, &ctx); + for url in std::mem::take(&mut self.pending_urls) { + ctx.open_url(egui::OpenUrl::new_tab(url)); + } + self.drain_requests(); + self.perf.frame_ms = now_ms() - t0; + } + + pub fn open_url(&mut self, url: &str) { + self.pending_urls.push(url.to_string()); + } + + fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + while let Ok(req) = rx.try_recv() { + let reply = req.reply.clone(); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { path } => { + self.shot_token += 1; + let token = self.shot_token; + self.queued_shots.push((token, now_ms() + 120.0, 0)); + self.pending_shots.push((token, path, reply, now_ms() + 8000.0)); + } + } + } + self.control_rx = Some(rx); + } + + fn issue_screenshots(&mut self, ctx: &egui::Context) { + let now = now_ms(); + self.queued_shots.retain_mut(|(token, at, frames)| { + *frames += 1; + if now >= *at && *frames >= 3 { + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); + false + } else { + true + } + }); + if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + fn collect_screenshots(&mut self, ctx: &egui::Context) { + if self.pending_shots.is_empty() { + return; + } + let events: Vec<_> = ctx.input(|i| { + i.raw + .events + .iter() + .filter_map(|e| match e { + egui::Event::Screenshot { user_data, image, .. } => { + let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; + Some((token, image.clone())) + } + _ => None, + }) + .collect() + }); + for (token, image) in events { + if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) { + let (_, path, reply, _) = self.pending_shots.remove(i); + let _ = reply.send(control::save_screenshot(self, &image, path.as_deref())); + } + } + let now = now_ms(); + self.pending_shots.retain(|(_, _, reply, deadline)| { + if now < *deadline { + return true; + } + let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use ui.render"})); + false + }); + } +} + +/// Engine key name for an egui key. +pub fn key_name(key: egui::Key) -> &'static str { + use egui::Key::*; + match key { + Enter => "Enter", + Escape => "Escape", + Backspace => "Backspace", + Delete => "Delete", + Tab => "Tab", + ArrowLeft => "Left", + ArrowRight => "Right", + ArrowUp => "Up", + ArrowDown => "Down", + Home => "Home", + End => "End", + PageUp => "PageUp", + PageDown => "PageDown", + Space => "Space", + F2 => "F2", + F5 => "F5", + F7 => "F7", + other => other.name(), + } +} + +pub fn now_ms() -> f64 { + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) + } + #[cfg(target_arch = "wasm32")] + { + js_now() + } +} + +#[cfg(target_arch = "wasm32")] +fn js_now() -> f64 { + // egui's input time is good enough for UI timing on the web. + 0.0 +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs new file mode 100644 index 0000000..91ca5dc --- /dev/null +++ b/crates/ui-egui/src/menus.rs @@ -0,0 +1,245 @@ +//! UI-level shortcuts, the command palette, and the menu structure (shared with the native macOS +//! menu bar in the desktop app). + +use serde_json::json; +use slidecraft_engine::Mods; + +use crate::SlideApp; +use crate::theme::{self, Tokens}; + +/// Shortcuts the UI handles itself (views, show, zoom, palette, file dialogs). True when consumed. +pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { + use egui::Key::*; + let id = match (key, m.cmd, m.shift, m.alt) { + (F5, _, false, _) => { + app.start_show(0, false); + return true; + } + (F5, _, true, _) | (Enter, true, false, false) => { + let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(i, false); + return true; + } + (Enter, true, true, _) => { + app.start_show(0, false); + return true; + } + (S, true, false, false) => { + app.save(); + return true; + } + (S, true, true, false) => "app.saveAsDialog", + (O, true, false, false) => "app.openDialog", + (P, true, true, false) => "app.palette", + (Comma, true, false, false) => "app.preferences", + (Equals | Plus, true, false, false) => "view.zoomIn", + (Minus, true, false, false) => "view.zoomOut", + (Num0, true, false, false) => "view.fit", + (F1, true, false, false) => "view.collapseRibbon", + (F9, false, true, false) => "view.gridlines", + (F9, false, false, true) => "view.guides", + (K, true, false, false) => { + app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); + return true; + } + (F, true, false, false) => { + app.palette = Some((String::new(), 0)); + return true; + } + _ => return false, + }; + let _ = app.run(id, json!({})); + true +} + +/// ⇧⌘P: search every command by name and run it. +pub fn palette(app: &mut SlideApp, ctx: &egui::Context) { + let Some((mut query, mut sel)) = app.palette.take() else { return }; + let t = Tokens::get(ctx); + let mut close = false; + let mut run: Option = None; + egui::Window::new("command_palette") + .title_bar(false) + .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 90.0)) + .fixed_size(egui::vec2(520.0, 380.0)) + .show(ctx, |ui| { + let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search commands…").desired_width(f32::INFINITY).font(theme::font(15.0))); + r.request_focus(); + let q = query.to_lowercase(); + let mut items: Vec<(String, String, Option<&'static str>)> = app + .session + .commands() + .into_iter() + .filter(|c| c.enabled) + .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut)) + .chain(crate::UI_COMMANDS.iter().map(|c| (c.0.to_string(), c.1.to_string(), c.2))) + .filter(|(id, label, _)| q.is_empty() || label.to_lowercase().contains(&q) || id.to_lowercase().contains(&q)) + .collect(); + items.sort_by_key(|(_, l, _)| !l.to_lowercase().starts_with(&q)); + items.truncate(60); + let (down, up, enter, esc) = ui.input(|i| { + ( + i.key_pressed(egui::Key::ArrowDown), + i.key_pressed(egui::Key::ArrowUp), + i.key_pressed(egui::Key::Enter), + i.key_pressed(egui::Key::Escape), + ) + }); + if down { + sel = (sel + 1).min(items.len().saturating_sub(1)); + } + if up { + sel = sel.saturating_sub(1); + } + if esc { + close = true; + } + if enter && let Some((id, ..)) = items.get(sel) { + run = Some(id.clone()); + } + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (k, (id, label, sc)) in items.iter().enumerate() { + let r = ui.horizontal(|ui| { + let resp = ui.selectable_label(k == sel, label); + if let Some(s) = sc { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(crate::ribbon::pretty_shortcut(s)).color(t.text_faint)); + }); + } + resp + }); + if r.inner.clicked() { + run = Some(id.clone()); + } + } + }); + }); + if let Some(id) = run { + let _ = app.run(&id, json!({})); + close = true; + } + if !close { + app.palette = Some((query, sel)); + } +} + +/// The application menu tree: (menu, [(label, command id, shortcut)]), "-" = separator. Used by the +/// native macOS menu bar and as the parity catalogue's live side. +pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { + vec![ + ( + "File", + vec![ + ("New Presentation", "file.new"), + ("Open…", "app.openDialog"), + ("-", ""), + ("Close", "file.close"), + ("Save", "file.save"), + ("Save As…", "app.saveAsDialog"), + ("Export…", "app.exportDialog"), + ("-", ""), + ("Properties", "file.properties"), + ], + ), + ( + "Edit", + vec![ + ("Undo", "edit.undo"), + ("Redo", "edit.redo"), + ("-", ""), + ("Cut", "edit.cut"), + ("Copy", "edit.copy"), + ("Paste", "edit.paste"), + ("Paste and Match Formatting", "edit.pasteText"), + ("-", ""), + ("Select All", "edit.selectAll"), + ("Duplicate", "edit.duplicate"), + ("Delete Slide", "slide.delete"), + ("-", ""), + ("Find…", "app.palette"), + ], + ), + ( + "View", + vec![ + ("Normal", "view.normal"), + ("Slide Sorter", "view.sorter"), + ("Notes Page", "view.notesPage"), + ("Outline View", "view.outline"), + ("Reading View", "view.reading"), + ("Slide Show", "show.start"), + ("-", ""), + ("Slide Master", "view.slideMaster"), + ("-", ""), + ("Ruler", "view.ruler"), + ("Gridlines", "view.gridlines"), + ("Guides", "view.guides"), + ("-", ""), + ("Zoom In", "view.zoomIn"), + ("Zoom Out", "view.zoomOut"), + ("Fit to Window", "view.fit"), + ], + ), + ( + "Insert", + vec![ + ("New Slide", "slide.new"), + ("Duplicate Slide", "slide.duplicate"), + ("-", ""), + ("Section", "section.add"), + ("Text Box", "insert.textBox"), + ("WordArt", "insert.wordArt"), + ("Date and Time", "insert.dateTime"), + ("Slide Number", "insert.slideNumber"), + ("-", ""), + ("Table…", "insert.table"), + ("Chart", "insert.chart"), + ("Picture from File…", "app.insertPictureDialog"), + ("Audio from File…", "app.insertAudioDialog"), + ("Video from File…", "app.insertVideoDialog"), + ], + ), + ( + "Format", + vec![ + ("Bold", "format.bold"), + ("Italic", "format.italic"), + ("Underline", "format.underline"), + ("-", ""), + ("Align Left", "format.alignLeft"), + ("Center", "format.alignCenter"), + ("Align Right", "format.alignRight"), + ("Justify", "format.justify"), + ], + ), + ( + "Arrange", + vec![ + ("Bring to Front", "arrange.bringToFront"), + ("Send to Back", "arrange.sendToBack"), + ("Bring Forward", "arrange.bringForward"), + ("Send Backward", "arrange.sendBackward"), + ("-", ""), + ("Group", "arrange.group"), + ("Ungroup", "arrange.ungroup"), + ("Regroup", "arrange.regroup"), + ("-", ""), + ("Rotate Left 90°", "arrange.rotateLeft"), + ("Rotate Right 90°", "arrange.rotateRight"), + ("Flip Horizontal", "arrange.flipHorizontal"), + ("Flip Vertical", "arrange.flipVertical"), + ], + ), + ( + "Slide Show", + vec![ + ("Play from Start", "show.fromStart"), + ("Play from Current Slide", "show.fromCurrent"), + ("-", ""), + ("Hide Slide", "slide.hide"), + ("Set Up Show…", "show.setup"), + ], + ), + ("Help", vec![("About SlideCraft", "app.about"), ("Command Palette", "app.palette")]), + ] +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs new file mode 100644 index 0000000..dc7c2fc --- /dev/null +++ b/crates/ui-egui/src/panes.rs @@ -0,0 +1,648 @@ +//! Task panes on the right: Format Shape, Format Background, Animation, Selection, Comments and +//! Design Ideas. + +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::{Value, json}; +use slidecraft_model::{AnimClass, AnimStart, Fill, ShapeKind}; + +use crate::icons::{self, Icon}; +use crate::ribbon::{cref_param, paint_anim_tile}; +use crate::theme::{self, Tokens}; +use crate::{SlideApp, widgets}; + +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(pane) = app.ui.pane.clone() else { return }; + egui::Panel::right("task_pane") + .resizable(true) + .default_size(app.ui.pane_width) + .size_range(240.0..=520.0) + .frame(egui::Frame::NONE.fill(t.card).inner_margin(egui::Margin::same(10))) + .show(ui, |ui| { + app.ui.pane_width = ui.available_width() + 20.0; + let title = match pane.as_str() { + "format" => "Format Shape", + "background" => "Format Background", + "animation" => "Animation Pane", + "selection" => "Selection", + "comments" => "Comments", + "designer" => "Design Ideas", + _ => "Pane", + }; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(title).font(theme::bold(16.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), r.shrink(3.0), Icon::Close, t.text_dim, false); + if resp.clicked() { + app.ui.pane = None; + } + }); + }); + ui.add_space(6.0); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match pane.as_str() { + "format" => format_shape(app, ui), + "background" => background(app, ui), + "animation" => animation(app, ui), + "selection" => selection(app, ui), + "comments" => comments(app, ui), + "designer" => designer(app, ui), + _ => {} + }); + }); +} + +fn run(app: &mut SlideApp, id: &str, p: Value) { + let _ = app.run(id, p); +} + +fn scheme(app: &SlideApp) -> slidecraft_color::ColorScheme { + app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.scheme())).unwrap_or_else(|| slidecraft_model::Theme::default().colors) +} + +fn color_button( + app: &mut SlideApp, + ui: &mut Ui, + label: &str, + none_label: Option<&str>, + on_pick: impl Fn(Option) -> (String, Value), +) { + let sc = scheme(app); + ui.horizontal(|ui| { + ui.label(label); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new("Color ▾").min_size(vec2(70.0, 22.0))); + egui::Popup::menu(&r).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, none_label) { + let (id, p) = on_pick(c); + run(app, &id, p); + } + }); + }); + }); +} + +fn format_shape(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let Some(sh) = st.selected_shapes().first().map(|s| (*s).clone()) else { + ui.label(egui::RichText::new("Select a shape to format it.").color(t.text_dim)); + return; + }; + let x = slidecraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh); + // Tabs as icons. + ui.horizontal(|ui| { + for (tab, icon, tip) in [ + ("fill", Icon::ShapeFill, "Fill & Line"), + ("effects", Icon::ShapeEffects, "Effects"), + ("size", Icon::SlideSize, "Size & Properties"), + ("text", Icon::TextBox, "Text Options"), + ] { + let on = app.ui.format_tab == tab; + if widgets::icon_toggle(ui, icon, tip, true, on).clicked() { + app.ui.format_tab = tab.into(); + } + } + if matches!(sh.kind, ShapeKind::Picture { .. }) + && widgets::icon_toggle(ui, Icon::Picture, "Picture", true, app.ui.format_tab == "picture").clicked() + { + app.ui.format_tab = "picture".into(); + } + }); + ui.separator(); + match app.ui.format_tab.as_str() { + "effects" => { + ui.label(egui::RichText::new("Shadow").font(theme::bold(13.0))); + ui.horizontal_wrapped(|ui| { + for (l, v) in [ + ("None", "none"), + ("Outer", "outer"), + ("Bottom", "bottom"), + ("Center", "center"), + ("Inner", "inner"), + ("Perspective", "perspective"), + ] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"shadow": v})); + } + } + }); + ui.add_space(6.0); + ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); + ui.horizontal_wrapped(|ui| { + for (l, v) in [("None", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"reflection": v})); + } + } + }); + ui.add_space(6.0); + ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); + let mut g = sh.effects.as_ref().and_then(|e| e.glow.as_ref().map(|g| g.radius)).unwrap_or(0.0); + if ui.add(egui::Slider::new(&mut g, 0.0..=40.0).text("size (pt)")).drag_stopped() { + run(app, "shape.effects", json!({"glow": if g > 0.0 { json!(g) } else { Value::Null }})); + } + ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0))); + let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0); + if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { + run(app, "shape.effects", json!({"softEdges": s})); + } + if ui.button("Reset Effects").clicked() { + run(app, "shape.effects", json!({"reset": true})); + } + } + "size" => { + let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, unit: f64| { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + if let Some(nv) = widgets::spinner(ui, id, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) { + run(app, cmd, json!({key: nv * unit})); + } + }); + }; + ui.label(egui::RichText::new("Size").font(theme::bold(13.0))); + fields(ui, "Height", "fh", x.h, "h", "shape.resize", 72.0); + fields(ui, "Width", "fw", x.w, "w", "shape.resize", 72.0); + fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", 1.0); + ui.add_space(6.0); + ui.label(egui::RichText::new("Position").font(theme::bold(13.0))); + fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", 72.0); + fields(ui, "Vertical", "fy", x.y, "y", "shape.move", 72.0); + ui.add_space(6.0); + ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0))); + let id = ui.id().with(("alt", sh.id.0)); + let mut alt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| sh.descr.clone())); + let r = ui.add(egui::TextEdit::multiline(&mut alt).desired_rows(3).desired_width(f32::INFINITY)); + if r.lost_focus() && alt != sh.descr { + run(app, "shape.altText", json!({"text": alt})); + } + ui.data_mut(|d| d.insert_temp(id, alt)); + } + "text" => { + ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0))); + ui.horizontal(|ui| { + ui.label("Vertical alignment"); + for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { + if ui.small_button(l).clicked() { + run(app, "format.anchor", json!({"anchor": a})); + } + } + }); + ui.horizontal_wrapped(|ui| { + for (l, m) in [("Do not Autofit", "none"), ("Shrink text on overflow", "shrink"), ("Resize shape to fit text", "resize")] { + if ui.small_button(l).clicked() { + run(app, "format.autofit", json!({"mode": m})); + } + } + }); + let wrap = sh.text.as_ref().and_then(|t| t.body.wrap).unwrap_or(true); + let mut w = wrap; + if ui.checkbox(&mut w, "Wrap text in shape").changed() { + run(app, "format.wrap", json!({"on": w})); + } + ui.label("Margins (pt)"); + let b = sh.text.as_ref().map(|t| t.body.clone()).unwrap_or_default(); + for (l, k, v) in [ + ("Left", "left", b.inset_l.unwrap_or(7.2)), + ("Right", "right", b.inset_r.unwrap_or(7.2)), + ("Top", "top", b.inset_t.unwrap_or(3.6)), + ("Bottom", "bottom", b.inset_b.unwrap_or(3.6)), + ] { + ui.horizontal(|ui| { + ui.add_sized(vec2(60.0, 20.0), egui::Label::new(l)); + if let Some(nv) = widgets::spinner(ui, &format!("m{k}"), v, 1.0, " pt", 60.0) { + run(app, "format.margins", json!({k: nv.max(0.0)})); + } + }); + } + ui.horizontal(|ui| { + ui.label("Columns"); + for n in 1..=3 { + if ui.small_button(n.to_string()).clicked() { + run(app, "format.columns", json!({"count": n, "spacing": 18})); + } + } + }); + } + "picture" => { + let adj = if let ShapeKind::Picture { fill } = &sh.kind { fill.adjust.clone() } else { Default::default() }; + let mut b = adj.brightness as f32; + if ui.add(egui::Slider::new(&mut b, -1.0..=1.0).text("Brightness")).drag_stopped() { + run(app, "picture.adjust", json!({"brightness": b})); + } + let mut c = adj.contrast as f32; + if ui.add(egui::Slider::new(&mut c, -1.0..=1.0).text("Contrast")).drag_stopped() { + run(app, "picture.adjust", json!({"contrast": c})); + } + let mut s = adj.saturation.unwrap_or(1.0) as f32; + if ui.add(egui::Slider::new(&mut s, 0.0..=4.0).text("Saturation")).drag_stopped() { + run(app, "picture.adjust", json!({"saturation": s})); + } + let alpha = if let ShapeKind::Picture { fill } = &sh.kind { fill.alpha.unwrap_or(1.0) } else { 1.0 }; + let mut tr = (1.0 - alpha) as f32; + if ui.add(egui::Slider::new(&mut tr, 0.0..=1.0).text("Transparency")).drag_stopped() { + run(app, "picture.adjust", json!({"transparency": tr})); + } + if ui.button("Reset Picture").clicked() { + run(app, "picture.reset", json!({})); + } + } + _ => { + // Fill & Line. + ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); + let kind = match &sh.fill { + None => "auto", + Some(Fill::None) => "none", + Some(Fill::Solid { .. }) => "solid", + Some(Fill::Gradient(_)) => "gradient", + Some(Fill::Picture(_)) => "picture", + Some(Fill::Pattern(_)) => "pattern", + _ => "auto", + }; + for (l, k) in + [("No fill", "none"), ("Solid fill", "solid"), ("Gradient fill", "gradient"), ("Pattern fill", "pattern"), ("Automatic", "auto")] + { + if ui.radio(kind == k, l).clicked() { + match k { + "none" => run(app, "shape.fill", json!({"none": true})), + "solid" => run(app, "shape.fill", json!({"color": "accent1"})), + "gradient" => run( + app, + "shape.fill", + json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": 90}}), + ), + "pattern" => run(app, "shape.fill", json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})), + _ => run(app, "shape.fill", json!({"reset": true})), + } + } + } + color_button(app, ui, "Color", Some("No Fill"), |c| match c { + Some(c) => ("shape.fill".into(), json!({"color": cref_param(&c)})), + None => ("shape.fill".into(), json!({"none": true})), + }); + ui.horizontal(|ui| { + ui.label("Transparency"); + let id = ui.id().with("ftr"); + let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(0.0)); + let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")); + ui.data_mut(|d| d.insert_temp(id, v)); + if r.drag_stopped() || r.lost_focus() { + let _ = app.run("shape.fill", json!({"transparency": v as f64 / 100.0})); + } + }); + if kind == "gradient" { + ui.horizontal(|ui| { + ui.label("Angle"); + for a in [0, 45, 90, 135, 180, 270] { + if ui.small_button(format!("{a}°")).clicked() { + run( + app, + "shape.fill", + json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": a}}), + ); + } + } + }); + } + ui.add_space(8.0); + ui.label(egui::RichText::new("Line").font(theme::bold(13.0))); + let none = matches!(sh.line.as_ref().and_then(|l| l.fill.as_ref()), Some(Fill::None)); + if ui.radio(none, "No line").clicked() { + run(app, "shape.line", json!({"none": true})); + } + if ui.radio(!none && sh.line.is_some(), "Solid line").clicked() { + run(app, "shape.line", json!({"color": "tx1", "width": 1})); + } + if ui.radio(sh.line.is_none(), "Automatic").clicked() { + run(app, "shape.line", json!({"reset": true})); + } + color_button(app, ui, "Color", Some("No Outline"), |c| match c { + Some(c) => ("shape.line".into(), json!({"color": cref_param(&c)})), + None => ("shape.line".into(), json!({"none": true})), + }); + ui.horizontal(|ui| { + ui.label("Width"); + let w = sh.line.as_ref().and_then(|l| l.width).unwrap_or(0.75); + if let Some(v) = widgets::spinner(ui, "lw", w, 0.25, " pt", 60.0) { + run(app, "shape.line", json!({"width": v.max(0.0)})); + } + }); + ui.horizontal_wrapped(|ui| { + ui.label("Dash"); + for (l, d) in [("—", "solid"), ("···", "sysDot"), ("- -", "dash"), ("-·-", "dashDot"), ("— —", "lgDash")] { + if ui.small_button(l).clicked() { + run(app, "shape.line", json!({"dash": d})); + } + } + }); + } + } +} + +fn background(app: &mut SlideApp, ui: &mut Ui) { + ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); + if ui.button("Solid fill: theme background").clicked() { + run(app, "design.background", json!({"color": "bg1"})); + } + let sc = scheme(app); + ui.horizontal(|ui| { + ui.label("Color"); + let r = ui.button("Color ▾"); + egui::Popup::menu(&r).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + run(app, "design.background", json!({"color": cref_param(&c)})); + } + }); + }); + ui.label("Gradient presets"); + ui.horizontal_wrapped(|ui| { + for (a, b) in [("accent1", "accent4"), ("accent2", "accent6"), ("dk2", "accent1"), ("lt2", "bg1"), ("accent3", "accent5")] { + let (r, resp) = ui.allocate_exact_size(vec2(44.0, 26.0), Sense::click()); + let ca = theme::to_color32(sc.get(slidecraft_color::SchemeSlot::from_xml(a).unwrap_or(slidecraft_color::SchemeSlot::Accent1))); + let cb = theme::to_color32(sc.get(slidecraft_color::SchemeSlot::from_xml(b).unwrap_or(slidecraft_color::SchemeSlot::Accent1))); + let mut mesh = egui::Mesh::default(); + mesh.colored_vertex(r.left_top(), ca); + mesh.colored_vertex(r.right_top(), cb); + mesh.colored_vertex(r.right_bottom(), cb); + mesh.colored_vertex(r.left_bottom(), ca); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + ui.painter().add(egui::Shape::mesh(mesh)); + if resp.clicked() { + run(app, "design.background", json!({"gradient": {"stops": [[0, a], [1, b]], "angle": 0}})); + } + } + }); + if ui.button("Picture fill…").clicked() + && let Some(pick) = app.services.pick_open.as_mut() + && let Some(path) = pick("picture") + && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) + { + let _ = app.run("design.background", json!({"picture": slidecraft_engine::cmd::base64_encode(&bytes)})); + } + let hide = app.session.active().and_then(|d| d.current_slide().map(|s| !s.show_master_shapes)).unwrap_or(false); + let mut h = hide; + if ui.checkbox(&mut h, "Hide background graphics").changed() { + run(app, "design.hideBackgroundGraphics", json!({"hide": h})); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Apply to All").clicked() { + let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); + if let Some(slidecraft_model::Background::Fill { fill: Fill::Solid { color } }) = bg { + run(app, "design.background", json!({"color": cref_param(&color), "all": true})); + } + } + if ui.button("Reset Background").clicked() { + run(app, "design.background", json!({"reset": true})); + } + }); +} + +fn animation(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let Some(slide) = st.current_slide() else { return }; + let anims = slide.animations.clone(); + let names: Vec<(u32, String)> = st.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect(); + let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); + ui.horizontal(|ui| { + if ui.button("▶ Play All").clicked() { + let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(from, true); + if let Some(s) = app.show.as_mut() { + s.preview_only = true; + s.autoplay = true; + } + } + }); + ui.separator(); + if anims.is_empty() { + ui.label(egui::RichText::new("Select an object on the slide and add an animation from the Animations tab.").color(t.text_dim)); + return; + } + let mut click = 0; + for (i, a) in anims.iter().enumerate() { + if a.start == AnimStart::OnClick { + click += 1; + } + let name = names.iter().find(|(id, _)| *id == a.shape.0).map(|(_, n)| n.clone()).unwrap_or_else(|| format!("Shape {}", a.shape)); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + let sel = selected.contains(&a.shape.0); + if sel || resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover }); + } + let p = ui.painter(); + p.text( + pos2(r.min.x + 6.0, r.center().y), + Align2::LEFT_CENTER, + if a.start == AnimStart::OnClick { click.to_string() } else { String::new() }, + theme::font(12.0), + t.text_dim, + ); + let icon = match a.class { + AnimClass::Entrance => Icon::AnimEntrance, + AnimClass::Emphasis => Icon::AnimEmphasis, + AnimClass::Exit => Icon::AnimExit, + _ => Icon::AnimPath, + }; + icons::paint(p, Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(18.0, 18.0)), icon, t.text, false); + p.text(pos2(r.min.x + 44.0, r.center().y), Align2::LEFT_CENTER, &name, theme::font(12.5), t.text); + // Timeline bar. + let bar_x = r.max.x - 80.0; + let w = (a.duration_ms as f32 / 1000.0 * 20.0).clamp(4.0, 70.0); + let col = match a.class { + AnimClass::Entrance => Color32::from_rgb(0x2E, 0xA3, 0x6F), + AnimClass::Emphasis => Color32::from_rgb(0xE3, 0xB2, 0x1C), + AnimClass::Exit => Color32::from_rgb(0xD8, 0x3F, 0x6B), + _ => Color32::from_rgb(0x2E, 0x6F, 0xD8), + }; + p.rect_filled( + Rect::from_min_size(pos2(bar_x + (a.delay_ms as f32 / 1000.0 * 20.0).min(40.0), r.center().y - 4.0), vec2(w, 8.0)), + CornerRadius::same(2), + col, + ); + if resp.clicked() { + run(app, "edit.select", json!({"ids": [a.shape.0]})); + } + resp.context_menu(|ui| { + for (l, v) in [("Start On Click", "onClick"), ("Start With Previous", "withPrevious"), ("Start After Previous", "afterPrevious")] { + if ui.button(l).clicked() { + run(app, "animation.timing", json!({"index": i, "start": v})); + ui.close(); + } + } + ui.separator(); + if ui.button("Move Up").clicked() { + run(app, "animation.move", json!({"index": i, "to": i.saturating_sub(1)})); + ui.close(); + } + if ui.button("Move Down").clicked() { + run(app, "animation.move", json!({"index": i, "to": i + 1})); + ui.close(); + } + if ui.button("Remove").clicked() { + run(app, "animation.remove", json!({"index": i})); + ui.close(); + } + }); + } + let _ = paint_anim_tile; + let _ = Stroke::NONE; +} + +fn selection(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let shapes: Vec<(u32, String, bool, &'static str)> = + st.shapes().iter().rev().map(|s| (s.id.0, s.name.clone(), s.hidden, s.kind_name())).collect(); + let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); + ui.horizontal(|ui| { + if ui.button("Show All").clicked() { + let ids: Vec = shapes.iter().map(|s| s.0).collect(); + run(app, "shape.visible", json!({"ids": ids, "visible": true})); + } + if ui.button("Hide All").clicked() { + let ids: Vec = shapes.iter().map(|s| s.0).collect(); + run(app, "shape.visible", json!({"ids": ids, "visible": false})); + } + }); + ui.separator(); + let n = shapes.len(); + for (k, (id, name, hidden, kind)) in shapes.into_iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + let sel = selected.contains(&id); + if sel || resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover }); + } + ui.painter().text( + pos2(r.min.x + 8.0, r.center().y), + Align2::LEFT_CENTER, + &name, + theme::font(12.5), + if hidden { t.text_faint } else { t.text }, + ); + ui.painter().text(pos2(r.max.x - 34.0, r.center().y), Align2::RIGHT_CENTER, kind, theme::font(10.5), t.text_faint); + let eye = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); + icons::paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, t.text_dim, false); + let eye_resp = ui.interact(eye, ui.id().with(("eye", id)), Sense::click()); + if eye_resp.clicked() { + run(app, "shape.visible", json!({"id": id, "visible": hidden})); + } else if resp.clicked() { + let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); + run(app, "edit.select", json!({"ids": [id], "toggle": add})); + } + if resp.double_clicked() { + let mut d = crate::dialogs::Dialog::new("renameShape"); + d.params = json!({"id": id, "name": name}); + app.dialog = Some(d); + } + resp.context_menu(|ui| { + if ui.button("Bring Forward").clicked() { + run(app, "arrange.bringForward", json!({"ids": [id]})); + ui.close(); + } + if ui.button("Send Backward").clicked() { + run(app, "arrange.sendBackward", json!({"ids": [id]})); + ui.close(); + } + if ui.button("Rename…").clicked() { + let mut d = crate::dialogs::Dialog::new("renameShape"); + d.params = json!({"id": id, "name": name}); + app.dialog = Some(d); + ui.close(); + } + }); + let _ = (k, n); + } +} + +fn comments(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + if ui.button("+ New Comment").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("comment")); + } + ui.separator(); + let Some(st) = app.session.active() else { return }; + let list = st.current_slide().map(|s| s.comments.clone()).unwrap_or_default(); + if list.is_empty() { + ui.label(egui::RichText::new("No comments on this slide.").color(t.text_dim)); + } + for (i, c) in list.iter().enumerate() { + egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(8)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); + ui.painter().circle_filled(r.center(), 12.0, t.accent); + ui.painter().text(r.center(), Align2::CENTER_CENTER, &c.initials, theme::bold(11.0), Color32::WHITE); + ui.vertical(|ui| { + ui.label(egui::RichText::new(&c.author).font(theme::bold(12.5))); + ui.label(egui::RichText::new(c.date.replace('T', " ").trim_end_matches('Z')).size(10.5).color(t.text_faint)); + }); + }); + ui.label(&c.text); + for r in &c.replies { + ui.label(egui::RichText::new(format!("↳ {}: {}", r.author, r.text)).color(t.text_dim)); + } + ui.horizontal(|ui| { + let id = ui.id().with(("reply", i)); + let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); + let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0)); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { + run(app, "comment.reply", json!({"index": i, "text": txt})); + txt.clear(); + } + ui.data_mut(|d| d.insert_temp(id, txt)); + if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { + run(app, "comment.resolve", json!({"index": i, "resolved": !c.resolved})); + } + if ui.small_button("🗑").clicked() { + run(app, "comment.delete", json!({"index": i})); + } + }); + }); + ui.add_space(6.0); + } +} + +/// Design Ideas: offline suggestions — the current slide in each theme and layout variations. +fn designer(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new("Pick a look for your slides. Ideas are generated on your device.").color(t.text_dim)); + ui.add_space(6.0); + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let idx = st.selection.slide; + let w = ui.available_width() - 8.0; + for th in slidecraft_model::theme::builtin_themes() { + let mut d = (*doc).clone(); + if let Some(m) = d.masters.first_mut() { + let m = std::sync::Arc::make_mut(m); + m.theme = th.clone(); + } + let ppp = ui.ctx().pixels_per_point(); + let px = (w * ppp) as u32; + let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32; + let img = slidecraft_render::render_slide( + &d, + idx, + &slidecraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, + ); + let tex = ui.ctx().load_texture(format!("idea-{}", th.name), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); + let (r, resp) = ui.allocate_exact_size(vec2(w, w * h as f32 / px.max(1) as f32), Sense::click()); + ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + ui.painter().rect_stroke( + r, + CornerRadius::same(2), + Stroke::new(if resp.hovered() { 2.5 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), + egui::StrokeKind::Outside, + ); + if resp.on_hover_text(&th.name).clicked() { + run(app, "design.theme", json!({"name": th.name})); + } + ui.add_space(8.0); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs new file mode 100644 index 0000000..cbf8895 --- /dev/null +++ b/crates/ui-egui/src/ribbon.rs @@ -0,0 +1,2345 @@ +//! Title bar, ribbon tabs and the ribbon card with every tab's groups. + +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::{Value, json}; +use slidecraft_engine::Target; +use slidecraft_model::{ShapeKind, anim::AnimClass}; + +use crate::SlideApp; +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; +use crate::widgets::{self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, group, icon_toggle, rows, small_button, small_button_ex}; + +pub const TITLE_H: f32 = 38.0; + +/// Is engine command `id` enabled right now? +pub fn enabled(app: &SlideApp, id: &str) -> bool { + match slidecraft_engine::find_command(id) { + Some(c) => (c.enabled)(&app.session).is_ok(), + None => crate::UI_COMMANDS.iter().any(|c| c.0 == id), + } +} + +fn run(app: &mut SlideApp, id: &str, p: Value) { + let _ = app.run(id, p); +} + +/// The window's title row: traffic-light space, quick access, centred title, actions. +pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::top("title_bar").exact_size(TITLE_H).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { + let rect = ui.max_rect(); + // Dragging the empty title bar moves the window. + let drag = ui.interact(rect, ui.id().with("titledrag"), Sense::click_and_drag()); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + let left = if app.integrated_titlebar { 84.0 } else { 10.0 }; + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(rect.min.x + left, rect.min.y), pos2(rect.min.x + 420.0, rect.max.y))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + child.spacing_mut().item_spacing.x = 2.0; + let has_doc = app.session.active().is_some(); + let dirty = app.session.active().is_some_and(|d| d.is_dirty()); + if icon_toggle(&mut child, Icon::Home, "Home", true, false).clicked() { + let _ = app.run("file.new", json!({})); + } + if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() { + app.save(); + } + if icon_toggle(&mut child, Icon::Undo, "Undo (⌘Z)", enabled(app, "edit.undo"), false).clicked() { + run(app, "edit.undo", json!({})); + } + if icon_toggle(&mut child, Icon::Redo, "Redo (⌘Y)", enabled(app, "edit.redo"), false).clicked() { + run(app, "edit.redo", json!({})); + } + if icon_toggle(&mut child, Icon::PlayFromStart, "Play from Start (F5)", has_doc, false).clicked() { + app.start_show(0, false); + } + let title = + app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "SlideCraft".into()); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, title, theme::font(13.0), t.text); + // Right side. + let mut right = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(rect.max.x - 320.0, rect.min.y), pos2(rect.max.x - 10.0, rect.max.y))) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + right.spacing_mut().item_spacing.x = 6.0; + if icon_toggle(&mut right, Icon::Search, "Search commands (⇧⌘P)", true, false).clicked() { + app.palette = Some((String::new(), 0)); + } + let share = right.add( + egui::Button::new(egui::RichText::new(" Present ").color(t.accent_text).font(theme::bold(13.0))) + .fill(t.accent) + .corner_radius(CornerRadius::same(13)) + .min_size(vec2(84.0, 26.0)), + ); + if share.clicked() && has_doc { + let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(from, false); + } + if icon_toggle(&mut right, Icon::CommentsBubble, "Comments", has_doc, app.ui.pane.as_deref() == Some("comments")).clicked() { + let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); + } + if icon_toggle( + &mut right, + if app.ui.brightness == theme::Brightness::Dark { Icon::Sparkle } else { Icon::Palette }, + "Light/Dark appearance", + true, + false, + ) + .clicked() + { + let _ = app.run("view.dark", json!({})); + } + }); +} + +/// Which contextual tabs apply to the selection. +fn contextual(app: &SlideApp) -> Vec<&'static str> { + let mut v = vec![]; + let Some(st) = app.session.active() else { return v }; + if st.selection.target != Target::Slides { + v.push("Slide Master"); + } + let shapes = st.selected_shapes(); + if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Shape | ShapeKind::Group { .. } | ShapeKind::Connector { .. })) + || st.selection.text.is_some() + { + v.push("Shape Format"); + } + if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Picture { .. })) { + v.push("Picture Format"); + } + if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Table(_))) || st.selection.text.as_ref().is_some_and(|t| t.cell.is_some()) { + v.push("Table Design"); + v.push("Layout"); + } + if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Chart(_))) { + v.push("Chart Design"); + } + if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Media(_))) { + v.push("Playback"); + } + v +} + +const TABS: [&str; 10] = ["Home", "Insert", "Draw", "Design", "Transitions", "Animations", "Slide Show", "Record", "Review", "View"]; + +/// Tab row and the ribbon card. +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let ctx_tabs = contextual(app); + if !TABS.contains(&app.ui.tab.as_str()) && !ctx_tabs.contains(&app.ui.tab.as_str()) { + app.ui.tab = "Home".into(); + } + let h = 28.0 + if app.ui.ribbon_collapsed { 4.0 } else { RIBBON_H + 20.0 }; + egui::Panel::top("ribbon").exact_size(h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { + let rect = ui.max_rect(); + // Tabs. + let mut x = rect.min.x + 12.0; + let y = rect.min.y + 2.0; + let all: Vec<(&str, bool)> = TABS.iter().map(|s| (*s, false)).chain(ctx_tabs.iter().map(|s| (*s, true))).collect(); + for (name, contextual) in all { + let font = theme::font(13.0); + let w = ui.fonts_mut(|f| f.layout_no_wrap(name.to_string(), font.clone(), t.text).size().x) + 18.0; + let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0)); + let resp = ui.interact(r, ui.id().with(("tab", name)), Sense::click()); + let active = app.ui.tab == name; + if resp.hovered() && !active { + ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover); + } + let col = if contextual { t.contextual } else { t.text }; + let f = if active { theme::bold(13.0) } else { font }; + ui.painter().text(r.center(), Align2::CENTER_CENTER, name, f, col); + if active { + let uw = (w - 20.0).max(16.0); + ui.painter().rect_filled(Rect::from_center_size(pos2(r.center().x, r.max.y - 1.0), vec2(uw, 3.0)), CornerRadius::same(1), t.accent); + } + if resp.clicked() { + if active { + app.ui.ribbon_collapsed = !app.ui.ribbon_collapsed; + } else { + app.ui.tab = name.to_string(); + app.ui.ribbon_collapsed = false; + } + } + if resp.double_clicked() { + app.ui.ribbon_collapsed = !app.ui.ribbon_collapsed; + } + x += w + 2.0; + } + if app.ui.ribbon_collapsed { + return; + } + // The white card. + let card = Rect::from_min_max(pos2(rect.min.x + 8.0, rect.min.y + 30.0), pos2(rect.max.x - 8.0, rect.min.y + 30.0 + RIBBON_H + 10.0)); + ui.painter().rect_filled(card.translate(vec2(0.0, 1.0)), CornerRadius::same(10), t.card_border); + ui.painter().rect_filled(card, CornerRadius::same(10), t.card); + let inner = Rect::from_min_size(pos2(card.min.x + 10.0, card.min.y + 5.0), vec2(card.width() - 20.0, RIBBON_H)); + let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Min))); + cui.set_clip_rect(inner.expand2(vec2(0.0, 3.0))); + cui.spacing_mut().item_spacing.x = 1.0; + let ui = &mut cui; + let tab = app.ui.tab.clone(); + match tab.as_str() { + "Home" => home(app, ui), + "Insert" => insert(app, ui), + "Draw" => draw(app, ui), + "Design" => design(app, ui), + "Transitions" => transitions(app, ui), + "Animations" => animations(app, ui), + "Slide Show" => slide_show(app, ui), + "Record" => record(app, ui), + "Review" => review(app, ui), + "View" => view(app, ui), + "Shape Format" => shape_format(app, ui), + "Picture Format" => picture_format(app, ui), + "Table Design" => table_design(app, ui), + "Layout" => table_layout(app, ui), + "Chart Design" => chart_design(app, ui), + "Playback" => playback(app, ui), + "Slide Master" => slide_master(app, ui), + _ => {} + } + }); +} + +fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { + let en = enabled(app, id); + if big_button(ui, icon, label, en).on_hover_text(tip(id, label)).clicked() { + run(app, id, p); + } +} + +fn small(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { + let en = enabled(app, id); + if small_button(ui, icon, label, en, false).on_hover_text(tip(id, label)).clicked() { + run(app, id, p); + } +} + +fn toggle(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, on: bool) { + let en = enabled(app, id); + if icon_toggle(ui, icon, &tip(id, label), en, on).clicked() { + run(app, id, json!({})); + } +} + +fn tip(id: &str, label: &str) -> String { + let sc = slidecraft_engine::find_command(id).and_then(|c| c.shortcut).or_else(|| crate::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.2)); + match sc { + Some(s) => format!("{label} ({})", pretty_shortcut(s)), + None => label.to_string(), + } +} + +pub fn pretty_shortcut(s: &str) -> String { + if cfg!(target_os = "macos") { + s.replace("Cmd+", "⌘").replace("Shift+", "⇧").replace("Alt+", "⌥").replace("Ctrl+", "⌃") + } else { + s.replace("Cmd+", "Ctrl+") + } +} + +fn scheme(app: &SlideApp) -> slidecraft_color::ColorScheme { + app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.scheme())).unwrap_or_else(|| slidecraft_model::Theme::default().colors) +} + +/// The formatting state at the selection (bold, size…). +pub fn fmt_state(app: &mut SlideApp) -> Value { + if app.session.active().is_none_or(|d| d.selection.shapes.is_empty() && d.selection.text.is_none()) { + return json!({}); + } + app.session.execute("format.state", &json!({})).unwrap_or_default() +} + +// ---------- Home ---------- + +fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let (main, arrow) = big_button_ex(ui, Icon::Paste, "Paste", enabled(app, "edit.paste"), true, false); + if main.on_hover_text("Paste (⌘V)").clicked() { + run(app, "edit.paste", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).show(|ui| { + if ui.button("Paste").clicked() { + run(app, "edit.paste", json!({})); + } + if ui.button("Paste and Match Formatting").clicked() { + run(app, "edit.pasteText", json!({})); + } + if ui.button("Paste Formatting").clicked() { + run(app, "format.painterApply", json!({})); + } + }); + } + rows(ui, |ui| { + small(app, ui, Icon::Cut, "Cut", "edit.cut", json!({})); + small(app, ui, Icon::Copy, "Copy", "edit.copy", json!({})); + let on = app.session.painter.is_some(); + let en = enabled(app, "format.painter"); + let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text("Format Painter (double-click to keep it on)"); + if r.double_clicked() { + run(app, "format.painter", json!({"sticky": true})); + } else if r.clicked() { + if on { + app.session.painter = None; + } else { + run(app, "format.painter", json!({})); + } + } + }); + }); +} + +fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { + let layouts: Vec<(String, slidecraft_model::LayoutId)> = app + .session + .active() + .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id)).collect())) + .unwrap_or_default(); + egui::Grid::new(("layouts", cmd)).spacing(vec2(6.0, 6.0)).show(ui, |ui| { + for (k, (name, id)) in layouts.iter().enumerate() { + let idx = app.session.active().and_then(|d| d.doc.masters.first().and_then(|m| m.layouts.iter().position(|l| l.id == *id))); + let resp = ui.vertical(|ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 54.0), Sense::click()); + if let (Some(d), Some(li)) = (app.session.active(), idx) { + let img = slidecraft_render::render_layout( + &d.doc, + 0, + Some(li), + &slidecraft_render::RenderOpts { + scale: 192.0 / d.doc.slide_size.width.max(1.0), + edit: true, + size: Some((192, 108)), + ..Default::default() + }, + ); + let tex = + ui.ctx().load_texture(format!("layout-{li}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); + ui.painter().image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke( + rect, + CornerRadius::ZERO, + Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), + egui::StrokeKind::Outside, + ); + ui.add_sized(vec2(96.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5)).truncate()); + resp + }); + if resp.inner.clicked() { + run(app, cmd, json!({"layout": id.0})); + ui.close(); + } + if k % 4 == 3 { + ui.end_row(); + } + } + }); +} + +fn slides_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); + if main.on_hover_text("New Slide (⇧⌘N)").clicked() { + run(app, "slide.new", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).width(420.0).show(|ui| { + layout_menu(app, ui, "slide.new"); + ui.separator(); + if ui.button("Duplicate Selected Slides").clicked() { + run(app, "slide.duplicate", json!({})); + } + if ui.button("Slides from Outline…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("outline")); + } + }); + } + rows(ui, |ui| { + let (r, _) = small_button_ex(ui, Icon::Layout, "Layout", enabled(app, "slide.layout"), false, true); + egui::Popup::menu(&r).width(420.0).show(|ui| layout_menu(app, ui, "slide.layout")); + small(app, ui, Icon::ResetSlide, "Reset", "slide.reset", json!({})); + let (r, _) = small_button_ex(ui, Icon::Section, "Section", enabled(app, "section.add"), false, true); + egui::Popup::menu(&r).show(|ui| { + if ui.button("Add Section").clicked() { + run(app, "section.add", json!({})); + } + if ui.button("Remove All Sections").clicked() { + run(app, "section.removeAll", json!({})); + } + }); + }); + }); +} + +pub fn font_families(app: &SlideApp) -> Vec { + let mut v = vec![]; + if let Some(m) = app.session.active().and_then(|d| d.doc.masters.first()) { + v.push(m.theme.fonts.major.latin.clone()); + v.push(m.theme.fonts.minor.latin.clone()); + } + v.extend(slidecraft_fonts::FontDb::global().families()); + v.dedup(); + v +} + +fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { + let en = enabled(app, "format.bold"); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); + let size = st.get("size").and_then(Value::as_f64); + let fams = font_families(app); + let theme_fonts: Vec = fams.iter().take(2).cloned().collect(); + dropdown(ui, "font", &font, 150.0, en, |ui| { + for (k, f) in fams.iter().enumerate() { + let label = if k == 0 && theme_fonts.len() == 2 { + format!("{f} (Headings)") + } else if k == 1 && theme_fonts.len() == 2 { + format!("{f} (Body)") + } else { + f.clone() + }; + if ui.button(egui::RichText::new(label)).clicked() { + run(app, "format.font", json!({"family": f})); + ui.close(); + } + if k == 1 { + ui.separator(); + } + } + }); + let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default(); + dropdown(ui, "size", &size_text, 52.0, en, |ui| { + for s in slidecraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) { + if ui.button(widgets::fmt_num(*s, "")).clicked() { + run(app, "format.size", json!({"size": s})); + ui.close(); + } + } + }); + toggle(app, ui, Icon::FontGrow, "Increase Font Size", "format.grow", false); + toggle(app, ui, Icon::FontShrink, "Decrease Font Size", "format.shrink", false); + toggle(app, ui, Icon::ClearFormatting, "Clear All Formatting", "format.clear", false); + }); + ui.add_space(3.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); + toggle(app, ui, Icon::Bold, "Bold", "format.bold", b("bold")); + toggle(app, ui, Icon::Italic, "Italic", "format.italic", b("italic")); + toggle(app, ui, Icon::Underline, "Underline", "format.underline", b("underline")); + toggle(app, ui, Icon::Strikethrough, "Strikethrough", "format.strikethrough", b("strike")); + toggle(app, ui, Icon::Superscript, "Superscript", "format.superscript", b("superscript")); + toggle(app, ui, Icon::Subscript, "Subscript", "format.subscript", b("subscript")); + let r = icon_toggle(ui, Icon::CharSpacing, "Character Spacing", en, false); + egui::Popup::menu(&r).show(|ui| { + for (label, pt) in [("Very Tight", -3.0), ("Tight", -1.5), ("Normal", 0.0), ("Loose", 3.0), ("Very Loose", 6.0)] { + if ui.button(label).clicked() { + run(app, "format.spacing", json!({"pt": pt})); + } + } + }); + let r = icon_toggle(ui, Icon::ChangeCase, "Change Case", en, false); + egui::Popup::menu(&r).show(|ui| { + for (label, mode) in [ + ("Sentence case.", "sentence"), + ("lowercase", "lower"), + ("UPPERCASE", "upper"), + ("Capitalize Each Word", "title"), + ("tOGGLE cASE", "toggle"), + ] { + if ui.button(label).clicked() { + run(app, "format.case", json!({"mode": mode})); + } + } + }); + let sc = scheme(app); + let (m, a) = color_split(ui, Icon::Highlight, "Text Highlight Color", Color32::from_rgb(255, 230, 0), en); + if m.clicked() { + run(app, "format.highlight", json!({"color": "#FFFF00"})); + } + egui::Popup::menu(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Color")) { + run(app, "format.highlight", json!({"color": c.map(|c| serde_json::to_value(cref_param(&c)).unwrap_or_default())})); + } + }); + let (m, a) = color_split(ui, Icon::FontColor, "Font Color", Color32::from_rgb(0xD8, 0x3F, 0x3F), en); + if m.clicked() { + run(app, "format.color", json!({"color": "#C00000"})); + } + egui::Popup::menu(&a).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + run(app, "format.color", json!({"color": cref_param(&c)})); + } + }); + }); + }); + }); +} + +/// A ColorRef as a command colour parameter. +pub fn cref_param(c: &slidecraft_model::ColorRef) -> Value { + match &c.base { + slidecraft_model::ColorBase::Scheme { slot } => { + let mut o = serde_json::Map::new(); + o.insert("scheme".into(), json!(slot.xml_name())); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } + slidecraft_model::ColorBase::Rgb { rgb } => json!(format!("#{}", rgb.hex())), + _ => json!("#000000"), + } +} + +fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { + let en = enabled(app, "format.bullets"); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); + let (m, a) = small_button_ex(ui, Icon::Bullets, "", en, b("bullets"), true); + if m.on_hover_text("Bullets").clicked() { + run(app, "format.bullets", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + if ui.button("None").clicked() { + run(app, "format.bullets", json!({"on": false})); + } + for ch in ["•", "○", "■", "□", "◆", "➢", "✓", "–", "★"] { + if ui.button(egui::RichText::new(format!("{ch} Bullet")).size(14.0)).clicked() { + run(app, "format.bullets", json!({"on": true, "char": ch})); + } + } + }); + } + let (m, a) = small_button_ex(ui, Icon::Numbering, "", en, b("numbering"), true); + if m.on_hover_text("Numbering").clicked() { + run(app, "format.numbering", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + if ui.button("None").clicked() { + run(app, "format.numbering", json!({"on": false})); + } + for (label, sch) in [ + ("1. 2. 3.", "arabicPeriod"), + ("1) 2) 3)", "arabicParenR"), + ("I. II. III.", "romanUcPeriod"), + ("i. ii. iii.", "romanLcPeriod"), + ("A. B. C.", "alphaUcPeriod"), + ("a) b) c)", "alphaLcParenR"), + ("a. b. c.", "alphaLcPeriod"), + ] { + if ui.button(label).clicked() { + run(app, "format.numbering", json!({"on": true, "scheme": sch})); + } + } + }); + } + toggle(app, ui, Icon::DecreaseIndent, "Decrease List Level", "format.outdent", false); + toggle(app, ui, Icon::IncreaseIndent, "Increase List Level", "format.indent", false); + let r = icon_toggle(ui, Icon::LineSpacing, "Line Spacing", en, false); + egui::Popup::menu(&r).show(|ui| { + for v in [1.0, 1.5, 2.0, 2.5, 3.0] { + if ui.button(format!("{v:.1}")).clicked() { + run(app, "format.lineSpacing", json!({"lines": v})); + } + } + if ui.button("Line Spacing Options…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); + } + }); + let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); + egui::Popup::menu(&r).show(|ui| { + for n in 1..=3 { + if ui.button(format!("{n} Column{}", if n > 1 { "s" } else { "" })).clicked() { + run(app, "format.columns", json!({"count": n, "spacing": 18})); + } + } + }); + }); + ui.add_space(3.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + let al = st.get("align").and_then(Value::as_str).unwrap_or(""); + toggle(app, ui, Icon::AlignLeft, "Align Left", "format.alignLeft", al == "l"); + toggle(app, ui, Icon::AlignCenter, "Center", "format.alignCenter", al == "ctr"); + toggle(app, ui, Icon::AlignRight, "Align Right", "format.alignRight", al == "r"); + toggle(app, ui, Icon::Justify, "Justify", "format.justify", al == "just"); + let r = icon_toggle(ui, Icon::TextDirection, "Text Direction", en, false); + egui::Popup::menu(&r).show(|ui| { + for (l, d) in [ + ("Horizontal", "horizontal"), + ("Rotate all text 90°", "rotate90"), + ("Rotate all text 270°", "rotate270"), + ("Stacked", "stacked"), + ] { + if ui.button(l).clicked() { + run(app, "format.direction", json!({"dir": d})); + } + } + }); + let r = icon_toggle(ui, Icon::AlignText, "Align Text", en, false); + egui::Popup::menu(&r).show(|ui| { + for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { + if ui.button(l).clicked() { + run(app, "format.anchor", json!({"anchor": a})); + } + } + }); + }); + }); + }); +} + +/// Shapes gallery popup body: every preset by category. Returns the chosen preset. +pub fn shapes_gallery(ui: &mut Ui) -> Option<&'static str> { + let t = Tokens::get(ui.ctx()); + let mut out = None; + ui.set_max_width(330.0); + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + for cat in slidecraft_geom::preset::Category::ALL { + ui.label(egui::RichText::new(cat.label()).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 1.0); + for p in slidecraft_geom::preset::CATALOG.iter().filter(|p| p.category == cat) { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); + } + paint_preset(ui.painter(), r.shrink(4.0), p.name, Color32::TRANSPARENT, t.text); + if resp.on_hover_text(p.label).clicked() { + out = Some(p.name); + } + } + }); + ui.add_space(4.0); + } + }); + if out.is_some() { + ui.close(); + } + out +} + +/// Draw a preset's outline (gallery icons). +pub fn paint_preset(p: &egui::Painter, r: Rect, name: &str, fill: Color32, stroke: Color32) { + let line = slidecraft_geom::preset::is_line_like(name); + let (w, h) = if line { (r.width() as f64, r.height() as f64) } else { (r.width() as f64, r.height() as f64) }; + let Some(g) = slidecraft_geom::preset::build(name, w, h, &[]) else { return }; + for sp in &g.paths { + let mut pts: Vec = vec![]; + let mut polys: Vec<(Vec, bool)> = vec![]; + kurbo_flatten(&sp.path, |el| match el { + Fl::Move(x, y) => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), false)); + } + pts.clear(); + pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)); + } + Fl::Line(x, y) => pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)), + Fl::Close => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), true)); + } + } + }); + if pts.len() > 1 { + polys.push((pts, false)); + } + for (poly, closed) in polys { + if closed && fill != Color32::TRANSPARENT && sp.fill != slidecraft_geom::preset::FillMode::None { + p.add(egui::Shape::Path(egui::epaint::PathShape { points: poly.clone(), closed: true, fill, stroke: Stroke::NONE.into() })); + } + if sp.stroke { + p.add(egui::Shape::Path(egui::epaint::PathShape { + points: poly, + closed, + fill: Color32::TRANSPARENT, + stroke: Stroke::new(1.0, stroke).into(), + })); + } + } + } +} + +enum Fl { + Move(f64, f64), + Line(f64, f64), + Close, +} + +fn kurbo_flatten(path: &slidecraft_geom::BezPath, mut f: impl FnMut(Fl)) { + slidecraft_geom::preset_flatten(path, &mut |el| match el { + slidecraft_geom::PathEl::MoveTo(p) => f(Fl::Move(p.x, p.y)), + slidecraft_geom::PathEl::LineTo(p) => f(Fl::Line(p.x, p.y)), + slidecraft_geom::PathEl::ClosePath => f(Fl::Close), + _ => {} + }); +} + +fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let en = enabled(app, "shape.insert"); + let r = big_button(ui, Icon::Shapes, "Shapes", en).on_hover_text("Shapes"); + egui::Popup::menu(&r).show(|ui| { + if let Some(p) = shapes_gallery(ui) { + app.session.set_tool(slidecraft_engine::ToolKind::Shape { preset: p.to_string() }); + } + }); + big(app, ui, Icon::TextBox, "Text Box", "insert.textBox", json!({})); + let sel = enabled(app, "shape.fill"); + let r = big_button(ui, Icon::Arrange, "Arrange", sel); + egui::Popup::menu(&r).show(|ui| arrange_menu(app, ui)); + let r = big_button(ui, Icon::QuickStyles, "Quick\nStyles", sel); + egui::Popup::menu(&r).width(330.0).show(|ui| quick_styles(app, ui)); + rows(ui, |ui| { + let sc = scheme(app); + let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); + if m.clicked() { + run(app, "shape.fill", json!({"color": "accent1"})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { + match c { + Some(c) => run(app, "shape.fill", json!({"color": cref_param(&c)})), + None => run(app, "shape.fill", json!({"none": true})), + } + } + }); + } + let (m, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); + if m.clicked() { + run(app, "shape.line", json!({"color": "tx1", "width": 1})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| outline_menu(app, ui, &sc)); + } + let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| effects_menu(app, ui)); + } + }); + }); +} + +pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &slidecraft_color::ColorScheme) { + if let Some(c) = widgets::color_grid(ui, sc, Some("No Outline")) { + match c { + Some(c) => run(app, "shape.line", json!({"color": cref_param(&c)})), + None => run(app, "shape.line", json!({"none": true})), + } + } + ui.menu_button("Weight", |ui| { + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { + if ui.button(format!("{w} pt")).clicked() { + run(app, "shape.line", json!({"width": w})); + } + } + }); + ui.menu_button("Dashes", |ui| { + for (l, d) in [ + ("Solid", "solid"), + ("Round Dot", "sysDot"), + ("Square Dot", "sysDash"), + ("Dash", "dash"), + ("Dash Dot", "dashDot"), + ("Long Dash", "lgDash"), + ("Long Dash Dot", "lgDashDot"), + ] { + if ui.button(l).clicked() { + run(app, "shape.line", json!({"dash": d})); + } + } + }); + ui.menu_button("Arrows", |ui| { + for (l, h, tl) in [ + ("None", "none", "none"), + ("Arrow at end", "none", "triangle"), + ("Arrow at start", "triangle", "none"), + ("Arrows at both ends", "triangle", "triangle"), + ("Stealth end", "none", "stealth"), + ("Oval ends", "oval", "oval"), + ] { + if ui.button(l).clicked() { + run(app, "shape.line", json!({"head": h, "tail": tl})); + } + } + }); +} + +pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { + ui.menu_button("Shadow", |ui| { + for (l, v) in [ + ("No Shadow", "none"), + ("Outer", "outer"), + ("Bottom", "bottom"), + ("Centered", "center"), + ("Inner", "inner"), + ("Perspective", "perspective"), + ] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"shadow": v})); + } + } + }); + ui.menu_button("Reflection", |ui| { + for (l, v) in [("No Reflection", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"reflection": v})); + } + } + }); + ui.menu_button("Glow", |ui| { + if ui.button("No Glow").clicked() { + run(app, "shape.effects", json!({"glow": null})); + } + for r in [5.0, 8.0, 11.0, 18.0] { + if ui.button(format!("{r} pt glow")).clicked() { + run(app, "shape.effects", json!({"glow": r})); + } + } + }); + ui.menu_button("Soft Edges", |ui| { + for r in [0.0, 1.0, 2.5, 5.0, 10.0, 25.0] { + if ui.button(if r == 0.0 { "No Soft Edges".to_string() } else { format!("{r} pt") }).clicked() { + run(app, "shape.effects", json!({"softEdges": r})); + } + } + }); + ui.menu_button("Bevel", |ui| { + for b in [ + "none", + "circle", + "relaxedInset", + "cross", + "coolSlant", + "angle", + "softRound", + "convex", + "slope", + "divot", + "riblet", + "hardEdge", + "artDeco", + ] { + if ui.button(b).clicked() { + run(app, "shape.effects", json!({"bevel": b})); + } + } + }); + if ui.button("Format Shape…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format", "tab": "effects"})); + } +} + +fn quick_styles(app: &mut SlideApp, ui: &mut Ui) { + let sc = scheme(app); + let slots = [ + slidecraft_color::SchemeSlot::Dk1, + slidecraft_color::SchemeSlot::Accent1, + slidecraft_color::SchemeSlot::Accent2, + slidecraft_color::SchemeSlot::Accent3, + slidecraft_color::SchemeSlot::Accent4, + slidecraft_color::SchemeSlot::Accent5, + slidecraft_color::SchemeSlot::Accent6, + ]; + egui::Grid::new("quickstyles").spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for row in 0..6 { + for (k, slot) in slots.iter().enumerate() { + let c = theme::to_color32(sc.get(*slot)); + let (rect, resp) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::click()); + let p = ui.painter(); + let (fill, stroke, text) = match row { + 0 => (Color32::WHITE, c, Color32::BLACK), + 2 => (c.gamma_multiply(0.35).to_opaque().lerp_to_gamma(Color32::WHITE, 0.6), Color32::TRANSPARENT, c), + 5 => (c.lerp_to_gamma(Color32::BLACK, 0.25), Color32::WHITE, Color32::WHITE), + _ => (c, c.lerp_to_gamma(Color32::BLACK, 0.3), Color32::WHITE), + }; + p.rect(rect.shrink(2.0), CornerRadius::same(3), fill, Stroke::new(1.5, stroke), egui::StrokeKind::Inside); + p.text(rect.center(), Align2::CENTER_CENTER, "Abc", theme::font(11.0), text); + if resp.hovered() { + p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.5, Tokens::get(ui.ctx()).accent), egui::StrokeKind::Inside); + } + if resp.clicked() { + run(app, "shape.quickStyle", json!({"index": row * 7 + k})); + ui.close(); + } + } + ui.end_row(); + } + }); +} + +pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { + ui.label(egui::RichText::new("Order Objects").font(theme::bold(12.0))); + for (l, id, ic) in [ + ("Bring to Front", "arrange.bringToFront", Icon::BringToFront), + ("Send to Back", "arrange.sendToBack", Icon::SendToBack), + ("Bring Forward", "arrange.bringForward", Icon::BringForward), + ("Send Backward", "arrange.sendBackward", Icon::SendBackward), + ] { + menu_item(app, ui, ic, l, id, json!({})); + } + ui.separator(); + ui.label(egui::RichText::new("Group Objects").font(theme::bold(12.0))); + menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); + menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); + menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); + ui.separator(); + ui.label(egui::RichText::new("Position Objects").font(theme::bold(12.0))); + ui.menu_button("Align", |ui| align_menu(app, ui)); + ui.menu_button("Rotate", |ui| { + menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); + menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); + menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); + menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); + }); + if ui.button("Selection Pane…").clicked() { + let _ = app.run("view.pane", json!({"pane": "selection"})); + ui.close(); + } +} + +pub fn align_menu(app: &mut SlideApp, ui: &mut Ui) { + for (l, e, ic) in [ + ("Align Left", "left", Icon::AlignObjLeft), + ("Align Center", "center", Icon::AlignObjCenter), + ("Align Right", "right", Icon::AlignObjRight), + ("Align Top", "top", Icon::AlignObjTop), + ("Align Middle", "middle", Icon::AlignObjMiddle), + ("Align Bottom", "bottom", Icon::AlignObjBottom), + ] { + menu_item(app, ui, ic, l, "arrange.align", json!({"edge": e})); + } + ui.separator(); + menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal"})); + menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical"})); +} + +pub fn menu_item(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { + let en = enabled(app, id); + let r = small_button(ui, icon, label, en, false); + if r.clicked() { + run(app, id, p); + ui.close(); + } +} + +fn home(app: &mut SlideApp, ui: &mut Ui) { + let st = fmt_state(app); + clipboard_group(app, ui); + slides_group(app, ui); + font_group(app, ui, &st); + paragraph_group(app, ui, &st); + group(ui, |ui| { + big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); + }); + drawing_group(app, ui); + group(ui, |ui| { + big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); + }); +} + +// ---------- Insert ---------- + +fn insert(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); + if main.clicked() { + run(app, "slide.new", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).width(420.0).show(|ui| layout_menu(app, ui, "slide.new")); + } + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Table, "Table", enabled(app, "insert.table")); + egui::Popup::menu(&r).show(|ui| { + if let Some((rr, cc)) = table_grid_picker(ui) { + run(app, "insert.table", json!({"rows": rr, "cols": cc})); + ui.close(); + } + ui.separator(); + if ui.button("Insert Table…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("table")); + ui.close(); + } + }); + }); + group(ui, |ui| { + big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({})); + let r = big_button(ui, Icon::Screenshot, "Screenshot", false); + let _ = r.on_hover_text("Screenshot (not available yet)"); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Shapes, "Shapes", enabled(app, "shape.insert")); + egui::Popup::menu(&r).show(|ui| { + if let Some(p) = shapes_gallery(ui) { + app.session.set_tool(slidecraft_engine::ToolKind::Shape { preset: p.to_string() }); + } + }); + let r = big_button(ui, Icon::Icons, "Icons", enabled(app, "shape.insert")); + egui::Popup::menu(&r).width(260.0).show(|ui| icon_library(app, ui)); + big(app, ui, Icon::SmartArt, "SmartArt", "app.dialog", json!({"id": "smartart"})); + let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart")); + egui::Popup::menu(&r).show(|ui| { + for k in slidecraft_model::chart::ChartType::MAIN { + if ui.button(k.label()).clicked() { + run(app, "insert.chart", json!({"type": format!("{k:?}")})); + } + } + }); + }); + group(ui, |ui| { + big(app, ui, Icon::Link, "Link", "app.dialog", json!({"id": "hyperlink"})); + let r = big_button(ui, Icon::Action, "Action", enabled(app, "insert.actionButton")); + egui::Popup::menu(&r).show(|ui| { + for k in ["back", "forward", "beginning", "end", "home", "information", "return", "movie", "document", "sound", "help", "custom"] { + if ui.button(format!("Action Button: {k}")).clicked() { + run(app, "insert.actionButton", json!({"kind": k})); + } + } + }); + }); + group(ui, |ui| { + big(app, ui, Icon::Comment, "Comment", "app.dialog", json!({"id": "comment"})); + }); + group(ui, |ui| { + big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); + big(app, ui, Icon::HeaderFooter, "Header &\nFooter", "app.dialog", json!({"id": "headerFooter"})); + let r = big_button(ui, Icon::WordArt, "WordArt", enabled(app, "insert.wordArt")); + egui::Popup::menu(&r).show(|ui| { + for i in 0..6 { + if ui.button(format!("WordArt style {}", i + 1)).clicked() { + run(app, "insert.wordArt", json!({"style": i})); + } + } + }); + big(app, ui, Icon::DateTime, "Date &\nTime", "insert.dateTime", json!({})); + big(app, ui, Icon::SlideNumber, "Slide\nNumber", "insert.slideNumber", json!({})); + }); + group(ui, |ui| { + big(app, ui, Icon::Equation, "Equation", "app.dialog", json!({"id": "equation"})); + big(app, ui, Icon::Symbol, "Symbol", "app.dialog", json!({"id": "symbol"})); + }); + group(ui, |ui| { + big(app, ui, Icon::Video, "Video", "app.insertVideoDialog", json!({})); + big(app, ui, Icon::Audio, "Audio", "app.insertAudioDialog", json!({})); + }); +} + +/// Hover-to-size table grid. Returns (rows, cols) when clicked. +pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> { + let t = Tokens::get(ui.ctx()); + let id = ui.id().with("tgrid"); + let mut hover: (usize, usize) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, 0))); + let cell = 16.0; + let label = if hover.0 > 0 { format!("{}x{} Table", hover.1, hover.0) } else { "Insert Table".into() }; + ui.label(label); + let (rect, resp) = ui.allocate_exact_size(vec2(10.0 * (cell + 2.0), 8.0 * (cell + 2.0)), Sense::click()); + if let Some(p) = resp.hover_pos() { + let c = ((p.x - rect.min.x) / (cell + 2.0)).floor() as usize + 1; + let r = ((p.y - rect.min.y) / (cell + 2.0)).floor() as usize + 1; + hover = (r.min(8), c.min(10)); + } + for r in 0..8 { + for c in 0..10 { + let cr = Rect::from_min_size(rect.min + vec2(c as f32 * (cell + 2.0), r as f32 * (cell + 2.0)), vec2(cell, cell)); + let on = r < hover.0 && c < hover.1; + ui.painter().rect( + cr, + CornerRadius::ZERO, + if on { t.accent.gamma_multiply(0.3) } else { t.card }, + Stroke::new(1.0, if on { t.accent } else { t.border }), + egui::StrokeKind::Inside, + ); + } + } + ui.data_mut(|d| d.insert_temp(id, hover)); + if resp.clicked() && hover.0 > 0 { Some(hover) } else { None } +} + +/// A small built-in icon library: our own simple pictograms built from preset shapes. +fn icon_library(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let items: [(&str, &str); 24] = [ + ("star5", "Star"), + ("heart", "Heart"), + ("lightningBolt", "Lightning"), + ("sun", "Sun"), + ("moon", "Moon"), + ("cloud", "Cloud"), + ("smileyFace", "Smile"), + ("noSmoking", "Not allowed"), + ("mathPlus", "Plus"), + ("mathMultiply", "Multiply"), + ("mathEqual", "Equals"), + ("chevron", "Chevron"), + ("rightArrow", "Arrow"), + ("quadArrow", "Move"), + ("uturnArrow", "Return"), + ("flowChartDecision", "Decision"), + ("flowChartMagneticDisk", "Database"), + ("flowChartDocument", "Document"), + ("wedgeRoundRectCallout", "Speech"), + ("cloudCallout", "Thought"), + ("teardrop", "Pin"), + ("donut", "Ring"), + ("irregularSeal1", "Burst"), + ("ribbon2", "Banner"), + ]; + ui.horizontal_wrapped(|ui| { + for (preset, label) in items { + let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); + } + paint_preset(ui.painter(), r.shrink(6.0), preset, t.text, t.text); + if resp.on_hover_text(label).clicked() { + let size = app.session.active().map(|d| d.doc.slide_size).unwrap_or(slidecraft_model::defaults::WIDE); + let _ = app.run("shape.insert", json!({"preset": preset, "rect": [size.width / 2.0 - 48.0, size.height / 2.0 - 48.0, 96, 96]})); + let _ = app.run("shape.fill", json!({"color": "tx1"})); + let _ = app.run("shape.line", json!({"none": true})); + ui.close(); + } + } + }); +} + +// ---------- Draw ---------- + +fn draw(app: &mut SlideApp, ui: &mut Ui) { + use slidecraft_engine::ToolKind; + let cur = app.session.tool.kind.clone(); + group(ui, |ui| { + let sel = matches!(cur, ToolKind::Select); + if big_button_ex(ui, Icon::LassoSelect, "Select", true, false, sel).0.clicked() { + app.session.set_tool(ToolKind::Select); + } + let er = matches!(&cur, ToolKind::Ink { mode, .. } if mode == "eraser"); + if big_button_ex(ui, Icon::Eraser, "Eraser", true, false, er).0.clicked() { + app.session.set_tool(ToolKind::Ink { mode: "eraser".into(), color: slidecraft_color::Rgba::BLACK, width: 2.0 }); + } + }); + group(ui, |ui| { + let pens: [(Icon, &str, &str, slidecraft_color::Rgba, f64); 5] = [ + (Icon::Pen, "Black Pen", "pen", slidecraft_color::Rgba::rgb(20, 20, 20), 1.5), + (Icon::Pen, "Red Pen", "pen", slidecraft_color::Rgba::rgb(0xD8, 0x3F, 0x3F), 1.5), + (Icon::Pen, "Blue Pen", "pen", slidecraft_color::Rgba::rgb(0x2E, 0x6F, 0xD8), 1.5), + (Icon::Pencil, "Pencil", "pen", slidecraft_color::Rgba::rgb(90, 90, 90), 1.0), + (Icon::HighlighterPen, "Highlighter", "highlighter", slidecraft_color::Rgba::rgb(255, 230, 0), 10.0), + ]; + for (icon, label, mode, color, width) in pens { + let on = matches!(&cur, ToolKind::Ink { mode: m, color: c, .. } if m == mode && *c == color); + if big_button_ex(ui, icon, label, true, false, on).0.clicked() { + app.session.set_tool(ToolKind::Ink { mode: mode.into(), color, width }); + } + } + }); + group(ui, |ui| { + let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", false); + let _ = r.on_hover_text("Ink to Shape (coming soon)"); + let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); + let _ = r.on_hover_text("Ink to Text (coming soon)"); + let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false); + let _ = r.on_hover_text("Ink to Math (coming soon)"); + }); +} + +// ---------- Design ---------- + +fn design(app: &mut SlideApp, ui: &mut Ui) { + let themes = slidecraft_model::theme::builtin_themes(); + let cur = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.name.clone())).unwrap_or_default(); + group(ui, |ui| { + let sel = themes.iter().position(|th| th.name == cur); + let names: Vec = themes.iter().map(|t| t.name.clone()).collect(); + if let Some(i) = widgets::gallery( + ui, + themes.len(), + vec2(84.0, 56.0), + sel, + |p, r, i| { + if let Some(th) = themes.get(i) { + paint_theme_tile(p, r, th); + } + }, + |i| names.get(i).cloned().unwrap_or_default(), + ) && let Some(n) = names.get(i) + { + run(app, "design.theme", json!({"name": n})); + } + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Colors, "Colors", true); + egui::Popup::menu(&r).width(240.0).show(|ui| { + for cs in slidecraft_model::theme::builtin_color_schemes() { + ui.horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(220.0, 20.0), Sense::click()); + let p = ui.painter(); + for (k, c) in cs.colors.iter().take(10).skip(2).enumerate() { + p.rect_filled( + Rect::from_min_size(rect.min + vec2(k as f32 * 12.0, 3.0), vec2(11.0, 14.0)), + CornerRadius::ZERO, + theme::to_color32(*c), + ); + } + p.text(rect.min + vec2(104.0, 10.0), Align2::LEFT_CENTER, &cs.name, theme::font(12.0), Tokens::get(ui.ctx()).text); + if resp.clicked() { + run(app, "design.colors", json!({"name": cs.name})); + ui.close(); + } + }); + } + }); + let r = big_button(ui, Icon::Fonts, "Fonts", true); + egui::Popup::menu(&r).width(220.0).show(|ui| { + for fs in slidecraft_model::theme::builtin_font_schemes() { + if ui.button(format!("{}\n {} / {}", fs.name, fs.major.latin, fs.minor.latin)).clicked() { + run(app, "design.fonts", json!({"name": fs.name})); + } + } + }); + let r = big_button(ui, Icon::BackgroundStyles, "Background\nStyles", true); + egui::Popup::menu(&r).show(|ui| { + egui::Grid::new("bgstyles").show(ui, |ui| { + let sc = scheme(app); + for i in 1..=12 { + let slots = [ + slidecraft_color::SchemeSlot::Lt1, + slidecraft_color::SchemeSlot::Lt2, + slidecraft_color::SchemeSlot::Dk2, + slidecraft_color::SchemeSlot::Dk1, + ]; + let base = theme::to_color32(sc.get(slots[(i - 1) % 4])); + let shade = match (i - 1) / 4 { + 0 => base, + 1 => base.lerp_to_gamma(Color32::GRAY, 0.15), + _ => base.lerp_to_gamma(Color32::BLACK, 0.12), + }; + let (rect, resp) = ui.allocate_exact_size(vec2(48.0, 28.0), Sense::click()); + ui.painter().rect(rect, CornerRadius::same(2), shade, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); + if resp.on_hover_text(format!("Style {i}")).clicked() { + run(app, "design.background", json!({"style": i, "all": true})); + ui.close(); + } + if i % 4 == 0 { + ui.end_row(); + } + } + }); + if ui.button("Reset Slide Background").clicked() { + run(app, "design.background", json!({"reset": true})); + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::SlideSize, "Slide\nSize", true); + egui::Popup::menu(&r).show(|ui| { + if ui.button("Standard (4:3)").clicked() { + run(app, "design.slideSize", json!({"preset": "standard"})); + } + if ui.button("Widescreen (16:9)").clicked() { + run(app, "design.slideSize", json!({"preset": "widescreen"})); + } + if ui.button("Page Setup…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("slideSize")); + } + }); + big(app, ui, Icon::FormatBackground, "Format\nBackground", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); + }); +} + +pub fn paint_theme_tile(p: &egui::Painter, r: Rect, th: &slidecraft_model::Theme) { + let bg = theme::to_color32(th.colors.get(slidecraft_color::SchemeSlot::Lt1)); + let fg = theme::to_color32(th.colors.get(slidecraft_color::SchemeSlot::Dk1)); + p.rect(r, CornerRadius::same(2), bg, Stroke::new(1.0, Color32::from_gray(200)), egui::StrokeKind::Inside); + p.text(r.min + vec2(8.0, r.height() * 0.42), Align2::LEFT_CENTER, "Aa", theme::font(r.height() * 0.38), fg); + let w = (r.width() - 16.0) / 6.0; + for k in 0..6 { + let c = theme::to_color32(th.colors.colors.get(4 + k).copied().unwrap_or(slidecraft_color::Rgba::BLACK)); + p.rect_filled(Rect::from_min_size(pos2(r.min.x + 8.0 + k as f32 * w, r.max.y - 11.0), vec2(w - 1.5, 5.0)), CornerRadius::ZERO, c); + } +} + +// ---------- Transitions ---------- + +fn transitions(app: &mut SlideApp, ui: &mut Ui) { + let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone())); + let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into()); + group(ui, |ui| { + if big_button(ui, Icon::Preview, "Preview", true).clicked() { + let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(from, true); + if let Some(s) = app.show.as_mut() { + s.preview_only = true; + } + } + }); + group(ui, |ui| { + let list = slidecraft_model::anim::TRANSITIONS; + let shown: Vec<_> = list.iter().take(12).collect(); + let sel = shown.iter().position(|x| x.0 == kind); + if let Some(i) = widgets::gallery( + ui, + shown.len(), + vec2(54.0, 56.0), + sel, + |p, r, i| paint_transition_tile(p, r, shown.get(i).map(|x| (x.0, x.1)).unwrap_or(("", ""))), + |i| shown.get(i).map(|x| x.1.to_string()).unwrap_or_default(), + ) && let Some(x) = shown.get(i) + { + run(app, "transition.set", json!({"kind": x.0})); + } + let more = ui.add(egui::Button::new("▾").min_size(vec2(14.0, 52.0))); + egui::Popup::menu(&more).width(480.0).show(|ui| { + for cat in ["Subtle", "Exciting", "Dynamic Content"] { + ui.label(egui::RichText::new(cat).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + for x in list.iter().filter(|x| x.2 == cat) { + let (r, resp) = ui.allocate_exact_size(vec2(56.0, 58.0), Sense::click()); + if resp.hovered() || x.0 == kind { + ui.painter().rect_filled(r, CornerRadius::same(4), Tokens::get(ui.ctx()).hover); + } + paint_transition_tile(ui.painter(), r.shrink(3.0), (x.0, x.1)); + if resp.clicked() { + run(app, "transition.set", json!({"kind": x.0})); + ui.close(); + } + } + }); + } + }); + }); + group(ui, |ui| { + let opts: &[&str] = slidecraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); + egui::Popup::menu(&r).show(|ui| { + for o in opts { + if ui.button(option_label(o)).clicked() { + run(app, "transition.options", json!({"option": o})); + } + } + }); + }); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Duration:"); + let d = cur.as_ref().map(|t| t.duration_ms as f64 / 1000.0).unwrap_or(1.0); + if let Some(v) = widgets::spinner(ui, "tdur", d, 0.25, "", 46.0) { + run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + ui.horizontal(|ui| { + let mut click = cur.as_ref().is_none_or(|t| t.advance_on_click); + if ui.checkbox(&mut click, "On Mouse Click").changed() { + run(app, "transition.timing", json!({"onClick": click})); + } + }); + ui.horizontal(|ui| { + let after = cur.as_ref().and_then(|t| t.advance_after_ms); + let mut on = after.is_some(); + if ui.checkbox(&mut on, "After:").changed() { + run(app, "transition.timing", json!({"after": if on { json!(after.unwrap_or(0)) } else { Value::Null }})); + } + if let Some(v) = widgets::spinner(ui, "tafter", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) { + run(app, "transition.timing", json!({"after": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + }); + big(app, ui, Icon::ApplyToAll, "Apply\nTo All", "transition.applyAll", json!({})); + }); +} + +fn option_label(o: &str) -> String { + match o { + "l" => "From Left".into(), + "r" => "From Right".into(), + "u" => "From Top".into(), + "d" => "From Bottom".into(), + "horz" => "Horizontal".into(), + "vert" => "Vertical".into(), + "in" => "In".into(), + "out" => "Out".into(), + other => { + let mut s = String::new(); + for (i, c) in other.chars().enumerate() { + if i == 0 { + s.extend(c.to_uppercase()); + } else if c.is_uppercase() { + s.push(' '); + s.push(c); + } else { + s.push(c); + } + } + s + } + } +} + +pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &str)) { + let t = Tokens::light(); + let pic = Rect::from_min_size(r.min + vec2(4.0, 2.0), vec2(r.width() - 8.0, r.height() - 18.0)); + let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); + let light = Color32::from_rgb(0xBF, 0xD4, 0xF5); + p.rect_filled(pic, CornerRadius::same(2), light); + match id { + "none" => { + p.rect_filled(pic, CornerRadius::same(2), Color32::from_rgb(0xE6, 0xEC, 0xF5)); + } + "fade" | "dissolve" | "flash" => { + p.rect_filled(pic, CornerRadius::same(2), blue.gamma_multiply(0.5)); + } + "push" | "cover" | "uncover" | "pan" | "conveyor" => { + p.rect_filled(Rect::from_min_max(pos2(pic.center().x, pic.min.y), pic.max), CornerRadius::same(2), blue); + p.arrow(pos2(pic.center().x + 10.0, pic.center().y), vec2(-12.0, 0.0), Stroke::new(1.5, Color32::WHITE)); + } + "wipe" | "reveal" => { + p.rect_filled(Rect::from_min_max(pic.min, pos2(pic.center().x, pic.max.y)), CornerRadius::same(2), blue); + } + "split" | "doors" | "window" | "curtains" => { + p.rect_filled( + Rect::from_min_max(pos2(pic.min.x + pic.width() * 0.3, pic.min.y), pos2(pic.max.x - pic.width() * 0.3, pic.max.y)), + CornerRadius::ZERO, + blue, + ); + } + "randomBar" | "blinds" | "comb" | "shred" => { + for k in 0..5 { + let x = pic.min.x + k as f32 * pic.width() / 5.0; + p.rect_filled(Rect::from_min_size(pos2(x, pic.min.y), vec2(pic.width() / 10.0, pic.height())), CornerRadius::ZERO, blue); + } + } + "checker" | "honeycomb" | "glitter" | "fracture" => { + for k in 0..12 { + if k % 2 == (k / 4) % 2 { + let x = pic.min.x + (k % 4) as f32 * pic.width() / 4.0; + let y = pic.min.y + (k / 4) as f32 * pic.height() / 3.0; + p.rect_filled(Rect::from_min_size(pos2(x, y), vec2(pic.width() / 4.0, pic.height() / 3.0)), CornerRadius::ZERO, blue); + } + } + } + "shape" | "zoom" | "ripple" | "vortex" | "clock" => { + p.circle_filled(pic.center(), pic.height() * 0.38, blue); + } + "morph" => { + p.circle_filled(pos2(pic.min.x + pic.width() * 0.3, pic.center().y), pic.height() * 0.25, blue); + p.rect_filled( + Rect::from_center_size(pos2(pic.max.x - pic.width() * 0.3, pic.center().y), vec2(pic.height() * 0.45, pic.height() * 0.45)), + CornerRadius::same(2), + blue.gamma_multiply(0.7), + ); + } + _ => { + // Fake 3-D: a tilted card. + let pts = vec![ + pos2(pic.min.x + 6.0, pic.min.y + 4.0), + pos2(pic.max.x - 3.0, pic.min.y + 1.0), + pos2(pic.max.x - 3.0, pic.max.y - 1.0), + pos2(pic.min.x + 6.0, pic.max.y - 4.0), + ]; + p.add(egui::Shape::convex_polygon(pts, blue, Stroke::NONE)); + } + } + p.rect_stroke(pic, CornerRadius::same(2), Stroke::new(1.0, Color32::from_rgb(0x9B, 0xB4, 0xDB)), egui::StrokeKind::Inside); + p.text(pos2(r.center().x, r.max.y - 7.0), Align2::CENTER_CENTER, label, theme::font(10.0), t.text); +} + +// ---------- Animations ---------- + +fn animations(app: &mut SlideApp, ui: &mut Ui) { + let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); + let cur: Option = app.session.active().and_then(|d| { + let s = d.current_slide()?; + let id = d.selection.shapes.first()?; + s.animations.iter().find(|a| a.shape == *id).cloned() + }); + group(ui, |ui| { + if big_button(ui, Icon::Preview, "Preview", true).clicked() { + let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(from, true); + if let Some(s) = app.show.as_mut() { + s.preview_only = true; + s.autoplay = true; + } + } + }); + group(ui, |ui| { + let quick: Vec<_> = slidecraft_model::anim::ANIMATIONS + .iter() + .filter(|a| { + matches!( + a.0, + "appear" + | "fade" + | "fly" + | "float" + | "split" + | "wipe" + | "zoom" + | "pulse" + | "spin" + | "growShrink" + | "fadeOut" + | "flyOut" + | "lines" + ) + }) + .collect(); + let sel = cur.as_ref().and_then(|c| quick.iter().position(|a| a.0 == c.effect)); + if let Some(i) = widgets::gallery( + ui, + quick.len(), + vec2(50.0, 56.0), + sel, + |p, r, i| { + if let Some(a) = quick.get(i) { + paint_anim_tile(p, r, a.1, a.2, has_sel); + } + }, + |i| quick.get(i).map(|a| a.1.to_string()).unwrap_or_default(), + ) && let Some(a) = quick.get(i) + { + run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); + } + let more = ui.add_enabled(has_sel, egui::Button::new("▾").min_size(vec2(14.0, 52.0))); + egui::Popup::menu(&more).width(420.0).show(|ui| { + for (cls, label) in + [(AnimClass::Entrance, "Entrance"), (AnimClass::Emphasis, "Emphasis"), (AnimClass::Exit, "Exit"), (AnimClass::Path, "Motion Paths")] + { + ui.label(egui::RichText::new(label).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + for a in slidecraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 == cls) { + let (r, resp) = ui.allocate_exact_size(vec2(56.0, 54.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), Tokens::get(ui.ctx()).hover); + } + paint_anim_tile(ui.painter(), r.shrink(2.0), a.1, a.2, true); + if resp.clicked() { + run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); + ui.close(); + } + } + }); + } + }); + let opts: &[&str] = cur.as_ref().and_then(|c| slidecraft_model::anim::animation_info(&c.effect, c.class)).map(|x| x.5).unwrap_or(&[]); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); + egui::Popup::menu(&r).show(|ui| { + for o in opts { + if ui.button(option_label(o)).clicked() { + run(app, "animation.options", json!({"option": o})); + } + } + ui.separator(); + for (l, b) in [("As One Object", "asOne"), ("By Paragraph", "byParagraph")] { + if ui.button(l).clicked() { + run(app, "animation.options", json!({"textBuild": b})); + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::AddAnimation, "Add\nAnimation", has_sel); + egui::Popup::menu(&r).width(260.0).show(|ui| { + for a in slidecraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 != AnimClass::Media) { + if ui + .button(format!( + "{} — {}", + a.1, + match a.2 { + AnimClass::Entrance => "Entrance", + AnimClass::Emphasis => "Emphasis", + AnimClass::Exit => "Exit", + _ => "Motion", + } + )) + .clicked() + { + run(app, "animation.add", json!({"effect": a.0, "class": a.2.xml()})); + } + } + }); + big(app, ui, Icon::AnimationPane, "Animation\nPane", "view.pane", json!({"pane": "animation"})); + let r = big_button(ui, Icon::Trigger, "Trigger", cur.is_some()); + egui::Popup::menu(&r).show(|ui| { + if ui.button("On Click of… (none)").clicked() { + run(app, "animation.timing", json!({"trigger": null})); + } + let shapes: Vec<(u32, String)> = + app.session.active().map(|d| d.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect()).unwrap_or_default(); + for (id, name) in shapes { + if ui.button(format!("On Click of {name}")).clicked() { + run(app, "animation.timing", json!({"trigger": id})); + } + } + }); + }); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Start:"); + let s = cur.as_ref().map(|c| match c.start { + slidecraft_model::AnimStart::OnClick => "On Click", + slidecraft_model::AnimStart::WithPrevious => "With Previous", + slidecraft_model::AnimStart::AfterPrevious => "After Previous", + }); + dropdown(ui, "astart", s.unwrap_or(""), 112.0, cur.is_some(), |ui| { + for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] { + if ui.button(l).clicked() { + run(app, "animation.timing", json!({"start": v})); + ui.close(); + } + } + }); + }); + ui.horizontal(|ui| { + ui.label("Duration:"); + if let Some(v) = widgets::spinner(ui, "adur", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0) { + run(app, "animation.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + ui.horizontal(|ui| { + ui.label("Delay:"); + if let Some(v) = widgets::spinner(ui, "adelay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) { + run(app, "animation.timing", json!({"delay": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + }); + }); +} + +pub fn paint_anim_tile(p: &egui::Painter, r: Rect, label: &str, class: AnimClass, enabled: bool) { + let t = Tokens::light(); + let icon = match class { + AnimClass::Entrance => Icon::AnimEntrance, + AnimClass::Emphasis => Icon::AnimEmphasis, + AnimClass::Exit => Icon::AnimExit, + _ => Icon::AnimPath, + }; + icons::paint(p, Rect::from_center_size(pos2(r.center().x, r.min.y + 18.0), vec2(28.0, 28.0)), icon, t.text, !enabled); + p.text(pos2(r.center().x, r.max.y - 8.0), Align2::CENTER_CENTER, label, theme::font(10.0), if enabled { t.text } else { t.text_faint }); +} + +// ---------- Slide Show / Record / Review / View ---------- + +fn slide_show(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + if big_button(ui, Icon::PlayFromStart, "Play from\nStart", true).clicked() { + app.start_show(0, false); + } + if big_button(ui, Icon::PlayFromCurrent, "Play from\nCurrent Slide", true).clicked() { + let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(i, false); + } + let on = app.ui.presenter_view; + if big_button_ex(ui, Icon::PresenterView, "Presenter\nView", true, false, on).0.clicked() { + app.ui.presenter_view = !on; + } + big(app, ui, Icon::CustomShow, "Custom\nShow", "app.dialog", json!({"id": "customShow"})); + }); + group(ui, |ui| { + big(app, ui, Icon::SetUpShow, "Set Up\nSlide Show", "app.dialog", json!({"id": "setupShow"})); + let hidden = app.session.active().and_then(|d| d.current_slide().map(|s| s.hidden)).unwrap_or(false); + if big_button_ex(ui, Icon::HideSlide, "Hide\nSlide", true, false, hidden).0.clicked() { + run(app, "slide.hide", json!({})); + } + }); + group(ui, |ui| { + if big_button(ui, Icon::RehearseTimings, "Rehearse\nTimings", true).clicked() { + app.start_show(0, false); + if let Some(s) = app.show.as_mut() { + s.rehearse = true; + } + } + let r = big_button(ui, Icon::Record, "Record", false); + let _ = r.on_hover_text("Recording narration needs audio capture (coming soon)"); + }); + group(ui, |ui| { + rows(ui, |ui| { + let mut use_t = app.session.active().is_none_or(|d| d.doc.show.use_timings); + if ui.checkbox(&mut use_t, "Use Timings").changed() { + run(app, "show.setup", json!({"useTimings": use_t})); + } + let mut narr = app.session.active().is_none_or(|d| !d.doc.show.without_narration); + if ui.checkbox(&mut narr, "Play Narrations").changed() { + run(app, "show.setup", json!({"noNarration": !narr})); + } + let mut mc = app.session.active().is_none_or(|d| d.doc.show.show_media_controls); + let _ = ui.checkbox(&mut mc, "Show Media Controls"); + }); + }); +} + +fn record(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::Cameo, "Cameo", false); + let _ = r.on_hover_text("Camera capture is not available yet"); + if big_button(ui, Icon::PlayFromStart, "From\nBeginning", true).clicked() { + app.start_show(0, false); + if let Some(s) = app.show.as_mut() { + s.rehearse = true; + } + } + if big_button(ui, Icon::PlayFromCurrent, "From\nCurrent Slide", true).clicked() { + let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(i, false); + if let Some(s) = app.show.as_mut() { + s.rehearse = true; + } + } + }); + group(ui, |ui| { + if big_button(ui, Icon::Help, "Learn\nMore", true).clicked() { + app.open_url(slidecraft_engine::links::APP_PAGE); + } + }); +} + +fn review(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"})); + let r = big_button(ui, Icon::Thesaurus, "Thesaurus", false); + let _ = r.on_hover_text("Thesaurus (coming soon)"); + }); + group(ui, |ui| { + big(app, ui, Icon::Accessibility, "Check\nAccessibility", "app.dialog", json!({"id": "accessibility"})); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Translate, "Translate", false); + let _ = r.on_hover_text("Translation needs a language model (not bundled)"); + big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); + }); + group(ui, |ui| { + big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"})); + big(app, ui, Icon::DeleteComment, "Delete", "comment.delete", json!({})); + let show = app.ui.pane.as_deref() == Some("comments"); + if big_button_ex(ui, Icon::ShowComments, "Show\nComments", true, false, show).0.clicked() { + let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); + } + }); +} + +fn view(app: &mut SlideApp, ui: &mut Ui) { + use crate::ViewMode; + group(ui, |ui| { + for (icon, label, id, mode) in [ + (Icon::ViewNormal, "Normal", "view.normal", Some(ViewMode::Normal)), + (Icon::ViewOutline, "Outline\nView", "view.outline", Some(ViewMode::Outline)), + (Icon::ViewSorter, "Slide\nSorter", "view.sorter", Some(ViewMode::Sorter)), + (Icon::ViewNotesPage, "Notes\nPage", "view.notesPage", Some(ViewMode::NotesPage)), + (Icon::ViewReading, "Reading\nView", "view.reading", None), + ] { + let sel = mode.is_some_and(|m| app.ui.view == m); + if big_button_ex(ui, icon, label, true, false, sel).0.clicked() { + let _ = app.run(id, json!({})); + } + } + }); + group(ui, |ui| { + let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides); + if big_button_ex(ui, Icon::SlideMaster, "Slide\nMaster", true, false, in_master).0.clicked() { + let _ = app.run(if in_master { "view.closeMaster" } else { "view.slideMaster" }, json!({})); + } + let r = big_button(ui, Icon::HandoutMaster, "Handout\nMaster", false); + let _ = r.on_hover_text("Handout Master (coming soon)"); + let r = big_button(ui, Icon::NotesMaster, "Notes\nMaster", false); + let _ = r.on_hover_text("Notes Master (coming soon)"); + }); + group(ui, |ui| { + rows(ui, |ui| { + let mut r = app.ui.ruler; + if ui.checkbox(&mut r, "Ruler").changed() { + app.ui.ruler = r; + } + let mut g = app.ui.gridlines; + if ui.checkbox(&mut g, "Gridlines").changed() { + app.ui.gridlines = g; + } + let mut gd = app.ui.guides; + if ui.checkbox(&mut gd, "Guides").changed() { + app.ui.guides = gd; + } + }); + let n = app.ui.notes; + if big_button_ex(ui, Icon::Notes, "Notes", true, false, n).0.clicked() { + app.ui.notes = !n; + } + }); + group(ui, |ui| { + let r = big_button(ui, Icon::ZoomGlass, "Zoom", true); + egui::Popup::menu(&r).show(|ui| { + for pct in [400, 200, 150, 100, 75, 66, 50, 33] { + if ui.button(format!("{pct}%")).clicked() { + let _ = app.run("view.zoom", json!({"percent": pct})); + } + } + }); + if big_button(ui, Icon::FitToWindow, "Fit to\nWindow", true).clicked() { + app.ui.zoom = None; + } + }); + group(ui, |ui| { + let gs = app.ui.grayscale; + if big_button_ex(ui, Icon::Grayscale, "Grayscale", true, false, gs).0.clicked() { + app.ui.grayscale = !gs; + } + let dark = app.ui.brightness == theme::Brightness::Dark; + if big_button_ex(ui, Icon::Palette, "Dark\nMode", true, false, dark).0.clicked() { + let _ = app.run("view.dark", json!({})); + } + }); +} + +// ---------- contextual tabs ---------- + +fn shape_format(app: &mut SlideApp, ui: &mut Ui) { + let sc = scheme(app); + let sel = enabled(app, "shape.fill"); + group(ui, |ui| { + let r = big_button(ui, Icon::Shapes, "Shapes", true); + egui::Popup::menu(&r).show(|ui| { + if let Some(p) = shapes_gallery(ui) { + app.session.set_tool(slidecraft_engine::ToolKind::Shape { preset: p.to_string() }); + } + }); + big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); + let r = big_button(ui, Icon::EditShape, "Edit\nShape", sel); + egui::Popup::menu(&r).show(|ui| { + ui.label("Change Shape"); + if let Some(p) = shapes_gallery(ui) { + run(app, "shape.change", json!({"preset": p})); + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::QuickStyles, "Shape\nStyles", sel); + egui::Popup::menu(&r).width(330.0).show(|ui| quick_styles(app, ui)); + rows(ui, |ui| { + let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); + if m.clicked() { + run(app, "shape.fill", json!({"color": "accent1"})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { + match c { + Some(c) => run(app, "shape.fill", json!({"color": cref_param(&c)})), + None => run(app, "shape.fill", json!({"none": true})), + } + } + if ui.button("Gradient…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"})); + } + }); + } + let (_, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| outline_menu(app, ui, &sc)); + } + let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| effects_menu(app, ui)); + } + }); + }); + group(ui, |ui| { + rows(ui, |ui| { + let (_, a) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true); + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + run(app, "format.color", json!({"color": cref_param(&c)})); + } + }); + } + let (_, a) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true); + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { + run(app, "format.textOutline", json!({"color": c.map(|c| cref_param(&c))})); + } + }); + } + let r = small_button(ui, Icon::TextEffects, "Text Effects", sel, false); + egui::Popup::menu(&r).show(|ui| { + if ui.button("Shadow").clicked() { + run(app, "format.textShadow", json!({})); + } + }); + }); + }); + group(ui, |ui| { + big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); + }); + arrange_group(app, ui); + size_group(app, ui); + group(ui, |ui| { + big(app, ui, Icon::FormatBackground, "Format\nPane", "view.pane", json!({"pane": "format"})); + }); +} + +fn arrange_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let sel = enabled(app, "arrange.bringForward"); + let (m, a) = big_button_ex(ui, Icon::BringForward, "Bring\nForward", sel, true, false); + if m.clicked() { + run(app, "arrange.bringForward", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + menu_item(app, ui, Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); + menu_item(app, ui, Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); + }); + } + let (m, a) = big_button_ex(ui, Icon::SendBackward, "Send\nBackward", sel, true, false); + if m.clicked() { + run(app, "arrange.sendBackward", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + menu_item(app, ui, Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); + menu_item(app, ui, Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); + }); + } + big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); + let r = big_button(ui, Icon::AlignObjects, "Align", sel); + egui::Popup::menu(&r).show(|ui| align_menu(app, ui)); + rows(ui, |ui| { + let (_, a) = small_button_ex(ui, Icon::Group, "Group", sel, false, true); + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); + menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); + menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); + }); + } + let (_, a) = small_button_ex(ui, Icon::Rotate, "Rotate", sel, false, true); + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); + menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); + menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); + menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); + }); + } + }); + }); +} + +fn size_group(app: &mut SlideApp, ui: &mut Ui) { + let bx = app + .session + .active() + .and_then(|d| d.selection.shapes.first().and_then(|id| d.shape(*id).map(|s| slidecraft_engine::cmd::xfrm_of(&d.doc, &d.selection, s)))); + group(ui, |ui| { + rows(ui, |ui| { + let Some(x) = bx else { + ui.label("Height:"); + ui.label("Width:"); + return; + }; + ui.horizontal(|ui| { + ui.label("Height:"); + if let Some(v) = widgets::spinner(ui, "sh", x.h / 72.0, 0.1, "\"", 54.0) { + run(app, "shape.resize", json!({"h": v.max(0.0) * 72.0})); + } + }); + ui.horizontal(|ui| { + ui.label("Width: "); + if let Some(v) = widgets::spinner(ui, "sw", x.w / 72.0, 0.1, "\"", 54.0) { + run(app, "shape.resize", json!({"w": v.max(0.0) * 72.0})); + } + }); + }); + }); +} + +fn picture_format(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", false); + let _ = r.on_hover_text("Remove Background (coming soon)"); + let r = big_button(ui, Icon::Corrections, "Corrections", true); + egui::Popup::menu(&r).show(|ui| { + for b in [-0.4, -0.2, 0.0, 0.2, 0.4] { + if ui.button(format!("Brightness {:+}%", (b * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"brightness": b})); + } + } + ui.separator(); + for c in [-0.4, -0.2, 0.0, 0.2, 0.4] { + if ui.button(format!("Contrast {:+}%", (c * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"contrast": c})); + } + } + }); + let r = big_button(ui, Icon::ColorAdjust, "Color", true); + egui::Popup::menu(&r).show(|ui| { + for s in [0.0, 0.33, 0.66, 1.0, 1.5, 2.0, 3.0] { + if ui.button(format!("Saturation {}%", (s * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"saturation": s})); + } + } + if ui.button("Grayscale").clicked() { + run(app, "picture.adjust", json!({"grayscale": true})); + } + }); + let r = big_button(ui, Icon::Transparency, "Transparency", true); + egui::Popup::menu(&r).show(|ui| { + for tr in [0.0, 0.15, 0.3, 0.5, 0.65, 0.8, 0.95] { + if ui.button(format!("{}%", (tr * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"transparency": tr})); + } + } + }); + rows(ui, |ui| { + small(app, ui, Icon::ChangePicture, "Change Picture", "app.insertPictureDialog", json!({})); + small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({})); + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::QuickStyles, "Picture\nStyles", true); + egui::Popup::menu(&r).show(|ui| { + if ui.button("Simple Frame, White").clicked() { + run(app, "shape.line", json!({"color": "#FFFFFF", "width": 6})); + run(app, "shape.effects", json!({"shadow": "outer"})); + } + if ui.button("Drop Shadow Rectangle").clicked() { + run(app, "shape.effects", json!({"shadow": "bottom"})); + } + if ui.button("Rounded Diagonal Corner").clicked() { + run(app, "shape.change", json!({"preset": "round2DiagRect"})); + } + if ui.button("Soft Edge Rectangle").clicked() { + run(app, "shape.effects", json!({"softEdges": 10})); + } + if ui.button("Reflected Rounded Rectangle").clicked() { + run(app, "shape.change", json!({"preset": "roundRect"})); + run(app, "shape.effects", json!({"reflection": "half"})); + } + if ui.button("Oval").clicked() { + run(app, "shape.change", json!({"preset": "ellipse"})); + } + }); + big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); + }); + arrange_group(app, ui); + group(ui, |ui| { + let r = big_button(ui, Icon::Crop, "Crop", true); + egui::Popup::menu(&r).show(|ui| { + ui.label("Crop to Shape"); + if let Some(p) = shapes_gallery(ui) { + run(app, "shape.change", json!({"preset": p})); + } + ui.separator(); + for (l, v) in [("Crop 10% each side", 0.1), ("Crop 20% each side", 0.2), ("Reset Crop", 0.0)] { + if ui.button(l).clicked() { + run(app, "picture.crop", json!({"left": v, "top": v, "right": v, "bottom": v})); + } + } + }); + }); + size_group(app, ui); +} + +fn table_design(app: &mut SlideApp, ui: &mut Ui) { + let tbl = app + .session + .active() + .and_then(|d| d.selected_shapes().into_iter().find_map(|s| if let ShapeKind::Table(t) = &s.kind { Some(t.clone()) } else { None })); + group(ui, |ui| { + rows(ui, |ui| { + let flags = tbl.as_ref().map(|t| (t.first_row, t.last_row, t.band_row, t.first_col, t.last_col, t.band_col)).unwrap_or_default(); + ui.horizontal(|ui| { + let mut a = flags.0; + if ui.checkbox(&mut a, "Header Row").changed() { + run(app, "table.options", json!({"headerRow": a})); + } + let mut d = flags.3; + if ui.checkbox(&mut d, "First Column").changed() { + run(app, "table.options", json!({"firstColumn": d})); + } + }); + ui.horizontal(|ui| { + let mut b = flags.1; + if ui.checkbox(&mut b, "Total Row").changed() { + run(app, "table.options", json!({"totalRow": b})); + } + let mut e = flags.4; + if ui.checkbox(&mut e, "Last Column").changed() { + run(app, "table.options", json!({"lastColumn": e})); + } + }); + ui.horizontal(|ui| { + let mut c = flags.2; + if ui.checkbox(&mut c, "Banded Rows").changed() { + run(app, "table.options", json!({"bandedRows": c})); + } + let mut f = flags.5; + if ui.checkbox(&mut f, "Banded Columns").changed() { + run(app, "table.options", json!({"bandedColumns": f})); + } + }); + }); + }); + group(ui, |ui| { + let styles = slidecraft_model::table::table_styles(); + let quick: Vec<_> = styles.iter().filter(|(id, _)| id.starts_with("medium2") || id == "none" || id == "grid").take(8).collect(); + let cur = tbl.as_ref().map(|t| t.style.clone()).unwrap_or_default(); + let sel = quick.iter().position(|(id, _)| *id == cur); + let sc = scheme(app); + if let Some(i) = widgets::gallery( + ui, + quick.len(), + vec2(48.0, 40.0), + sel, + |p, r, i| { + if let Some((id, _)) = quick.get(i) { + paint_table_style(p, r, id, &sc); + } + }, + |i| quick.get(i).map(|x| x.1.clone()).unwrap_or_default(), + ) && let Some((id, _)) = quick.get(i) + { + run(app, "table.style", json!({"style": id})); + } + let more = ui.add(egui::Button::new("▾").min_size(vec2(14.0, 40.0))); + egui::Popup::menu(&more).width(360.0).show(|ui| { + ui.horizontal_wrapped(|ui| { + for (id, label) in &styles { + let (r, resp) = ui.allocate_exact_size(vec2(48.0, 36.0), Sense::click()); + paint_table_style(ui.painter(), r.shrink(2.0), id, &sc); + if resp.on_hover_text(label).clicked() { + run(app, "table.style", json!({"style": id})); + ui.close(); + } + } + }); + }); + }); + group(ui, |ui| { + let sc = scheme(app); + let (m, a) = color_split(ui, Icon::Shading, "Shading", Color32::from_rgb(0x2E, 0x6F, 0xD8), true); + if m.clicked() { + run(app, "table.cellFill", json!({"color": "accent1"})); + } + egui::Popup::menu(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { + match c { + Some(c) => run(app, "table.cellFill", json!({"color": cref_param(&c)})), + None => run(app, "table.cellFill", json!({"none": true})), + } + } + }); + }); +} + +pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &slidecraft_color::ColorScheme) { + let slot = match id.rsplit('-').next().unwrap_or("accent1") { + "accent2" => slidecraft_color::SchemeSlot::Accent2, + "accent3" => slidecraft_color::SchemeSlot::Accent3, + "accent4" => slidecraft_color::SchemeSlot::Accent4, + "accent5" => slidecraft_color::SchemeSlot::Accent5, + "accent6" => slidecraft_color::SchemeSlot::Accent6, + "tx1" => slidecraft_color::SchemeSlot::Dk1, + _ => slidecraft_color::SchemeSlot::Accent1, + }; + let a = theme::to_color32(sc.get(slot)); + let kind = id.split('-').next().unwrap_or(""); + for row in 0..4 { + let y = r.min.y + row as f32 * r.height() / 4.0; + let rr = Rect::from_min_size(pos2(r.min.x, y), vec2(r.width(), r.height() / 4.0)); + let c = match (kind, row) { + ("none", _) | ("grid", _) => Color32::WHITE, + ("light1", 0) | ("light2", _) => Color32::WHITE, + ("dark1", 0) => Color32::BLACK, + ("dark1", _) => a.lerp_to_gamma(Color32::BLACK, 0.3), + (_, 0) => a, + (_, k) if k % 2 == 1 => a.lerp_to_gamma(Color32::WHITE, 0.6), + _ => a.lerp_to_gamma(Color32::WHITE, 0.8), + }; + p.rect_filled(rr, CornerRadius::ZERO, c); + p.line_segment([rr.left_bottom(), rr.right_bottom()], Stroke::new(0.5, if kind == "grid" { Color32::GRAY } else { Color32::WHITE })); + } + if kind == "grid" || kind == "light2" { + p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.8, if kind == "grid" { Color32::GRAY } else { a }), egui::StrokeKind::Inside); + } +} + +fn table_layout(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::DeleteTable, "Delete", true); + egui::Popup::menu(&r).show(|ui| { + menu_item(app, ui, Icon::DeleteTable, "Delete Columns", "table.deleteColumn", json!({})); + menu_item(app, ui, Icon::DeleteTable, "Delete Rows", "table.deleteRow", json!({})); + menu_item(app, ui, Icon::DeleteTable, "Delete Table", "edit.delete", json!({})); + }); + big(app, ui, Icon::InsertAbove, "Insert\nAbove", "table.insertRowAbove", json!({})); + big(app, ui, Icon::InsertBelow, "Insert\nBelow", "table.insertRowBelow", json!({})); + big(app, ui, Icon::InsertLeft, "Insert\nLeft", "table.insertColumnLeft", json!({})); + big(app, ui, Icon::InsertRight, "Insert\nRight", "table.insertColumnRight", json!({})); + }); + group(ui, |ui| { + big(app, ui, Icon::MergeCells, "Merge\nCells", "table.merge", json!({})); + big(app, ui, Icon::SplitCells, "Split\nCells", "table.split", json!({})); + }); + group(ui, |ui| { + rows(ui, |ui| { + small(app, ui, Icon::DistributeV, "Distribute Rows", "table.distributeRows", json!({})); + small(app, ui, Icon::DistributeH, "Distribute Columns", "table.distributeColumns", json!({})); + }); + }); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + toggle(app, ui, Icon::AlignLeft, "Align Left", "format.alignLeft", false); + toggle(app, ui, Icon::AlignCenter, "Center", "format.alignCenter", false); + toggle(app, ui, Icon::AlignRight, "Align Right", "format.alignRight", false); + }); + ui.horizontal(|ui| { + for (l, a) in [("Top", "top"), ("Center", "middle"), ("Bottom", "bottom")] { + if ui.small_button(l).clicked() { + run(app, "format.anchor", json!({"anchor": a})); + } + } + }); + }); + }); + arrange_group(app, ui); +} + +fn chart_design(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true); + egui::Popup::menu(&r).show(|ui| { + for k in slidecraft_model::chart::ChartType::MAIN { + if ui.button(k.label()).clicked() { + // Replace the chart's type keeping its data. + if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) { + let _ = app.run("chart.type", json!({"id": id.0, "type": format!("{k:?}")})); + } + } + } + }); + big(app, ui, Icon::EditData, "Edit\nData", "app.dialog", json!({"id": "chartData"})); + }); + arrange_group(app, ui); +} + +fn playback(app: &mut SlideApp, ui: &mut Ui) { + let m = app + .session + .active() + .and_then(|d| d.selected_shapes().into_iter().find_map(|s| if let ShapeKind::Media(m) = &s.kind { Some(m.clone()) } else { None })); + group(ui, |ui| { + if big_button(ui, Icon::Play, "Play", m.is_some()).clicked() { + app.set_status("Media plays in the slide show (Present)."); + } + big(app, ui, Icon::TrimMedia, "Trim", "app.dialog", json!({"id": "trim"})); + }); + group(ui, |ui| { + rows(ui, |ui| { + let mut auto = m.as_ref().is_some_and(|x| x.autoplay); + if ui.checkbox(&mut auto, "Start Automatically").changed() { + run(app, "media.options", json!({"autoplay": auto})); + } + let mut across = m.as_ref().is_some_and(|x| x.play_across_slides); + if ui.checkbox(&mut across, "Play Across Slides").changed() { + run(app, "media.options", json!({"acrossSlides": across})); + } + let mut lp = m.as_ref().is_some_and(|x| x.loop_play); + if ui.checkbox(&mut lp, "Loop until Stopped").changed() { + run(app, "media.options", json!({"loop": lp})); + } + }); + rows(ui, |ui| { + let mut hide = m.as_ref().is_some_and(|x| x.hide_while_not_playing); + if ui.checkbox(&mut hide, "Hide During Show").changed() { + run(app, "media.options", json!({"hide": hide})); + } + let mut rw = m.as_ref().is_some_and(|x| x.rewind); + if ui.checkbox(&mut rw, "Rewind after Playing").changed() { + run(app, "media.options", json!({"rewind": rw})); + } + let mut fs = m.as_ref().is_some_and(|x| x.full_screen); + if ui.checkbox(&mut fs, "Play Full Screen").changed() { + run(app, "media.options", json!({"fullScreen": fs})); + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Volume, "Volume", m.is_some()); + egui::Popup::menu(&r).show(|ui| { + for (l, v) in [("Low", 0.25), ("Medium", 0.5), ("High", 1.0), ("Mute", 0.0)] { + if ui.button(l).clicked() { + run(app, "media.options", json!({"volume": v})); + } + } + }); + }); +} + +fn slide_master(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + big(app, ui, Icon::NewSlide, "Insert\nLayout", "master.insertLayout", json!({})); + big(app, ui, Icon::DeleteSlide, "Delete", "master.deleteLayout", json!({})); + big(app, ui, Icon::Layout, "Rename", "app.dialog", json!({"id": "renameLayout"})); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Table, "Insert\nPlaceholder", true); + egui::Popup::menu(&r).show(|ui| { + for (l, k) in + [("Content", "content"), ("Text", "text"), ("Picture", "picture"), ("Chart", "chart"), ("Table", "table"), ("Media", "media")] + { + if ui.button(l).clicked() { + run(app, "master.insertPlaceholder", json!({"kind": k})); + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Themes, "Themes", true); + egui::Popup::menu(&r).show(|ui| { + for th in slidecraft_model::theme::builtin_themes() { + if ui.button(&th.name).clicked() { + run(app, "design.theme", json!({"name": th.name})); + } + } + }); + big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { + run(app, "view.closeMaster", json!({})); + } + }); +} diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs new file mode 100644 index 0000000..a11923b --- /dev/null +++ b/crates/ui-egui/src/show.rs @@ -0,0 +1,606 @@ +//! Slide Show: full-screen playback with transitions and animations (`slidecraft-anim`), +//! keyboard/mouse navigation, pen, black/white screens, presenter view and rehearsed timings. + +use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2}; +use serde_json::json; +use slidecraft_anim::{Layer, ShowAction, ShowState, Source, Timeline}; +use slidecraft_model::{Presentation, ShapeId}; +use slidecraft_render::{RenderOpts, ShapeState}; + +use crate::SlideApp; +use crate::theme; + +pub struct Show { + pub state: ShowState, + pub reading: bool, + pub presenter: bool, + /// Preview from the Transitions/Animations tab: exits after the current slide. + pub preview_only: bool, + /// Play every step automatically (animation preview). + pub autoplay: bool, + pub rehearse: bool, + timeline: Option<(usize, Timeline)>, + step_start: f64, + slide_start: f64, + step_end: f64, + trans: Option, + tex: Option<(Key, TextureHandle)>, + next_tex: Option<(usize, TextureHandle)>, + blank: Option, + pen: bool, + strokes: Vec>, + goto: String, + started: bool, + show_start: f64, + timings: Vec<(usize, f64)>, + last_move: f64, + pub ended: bool, +} + +#[derive(Clone, PartialEq)] +struct Key { + slide: usize, + step: usize, + size: (u32, u32), + doc: usize, +} + +struct Transition { + old: Option, + start: f64, + kind: String, + option: String, + dur: f64, +} + +impl Show { + pub fn new(doc: &Presentation, from: usize, reading: bool, presenter: bool) -> Self { + Show { + state: ShowState::start_at(doc, from), + reading, + presenter, + preview_only: false, + autoplay: false, + rehearse: false, + timeline: None, + step_start: 0.0, + slide_start: 0.0, + step_end: 0.0, + trans: None, + tex: None, + next_tex: None, + blank: None, + pen: false, + strokes: vec![], + goto: String::new(), + started: false, + show_start: 0.0, + timings: vec![], + last_move: 0.0, + ended: false, + } + } + + fn timeline(&mut self, doc: &Presentation) -> &Timeline { + let i = self.state.slide; + if self.timeline.as_ref().is_none_or(|(k, _)| *k != i) { + self.timeline = Some((i, ShowState::timeline(doc, i))); + } + match &self.timeline { + Some((_, t)) => t, + None => { + static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); + EMPTY.get_or_init(Timeline::default) + } + } + } + + fn apply(&mut self, a: ShowAction, doc: &Presentation, now: f64) -> bool { + match a { + ShowAction::PlayStep(_) => { + self.step_start = now; + } + ShowAction::FinishStep(_) | ShowAction::StepBack(_) => { + self.step_end = now; + } + ShowAction::Slide(i) => { + if self.rehearse { + self.timings.push((self.timeline.as_ref().map(|t| t.0).unwrap_or(i), now - self.slide_start)); + } + let (kind, option, dur) = doc + .slides + .get(i) + .and_then(|s| s.transition.as_ref()) + .map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0)) + .unwrap_or(("none".into(), String::new(), 0.0)); + let old = self.tex.as_ref().map(|(_, t)| t.clone()); + self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation { + Some(Transition { old, start: now, kind, option, dur }) + } else { + None + }; + self.slide_start = now; + self.step_start = now; + self.step_end = now; + self.strokes.clear(); + if self.preview_only { + return false; + } + self.after_enter(doc, now); + } + ShowAction::End => { + self.ended = true; + if self.preview_only { + return false; + } + } + ShowAction::Exit => return false, + ShowAction::None => {} + } + true + } + + /// On entering a slide: auto-play its first step when it begins With/After Previous. + fn after_enter(&mut self, doc: &Presentation, now: f64) { + let auto = self.timeline(doc).step_is_auto(0); + if auto || self.autoplay { + let a = self.state.next(doc); + if matches!(a, ShowAction::PlayStep(_)) { + self.step_start = now; + } + } + } +} + +fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState { + let a = tl.state(id, None, step, t); + ShapeState { + visible: a.visible, + opacity: a.opacity, + offset: slidecraft_geom::Vec2::new(a.offset_x, a.offset_y), + scale: (a.scale_x, a.scale_y), + rotate: a.rotate, + clip: a.clip, + tint: a.tint, + } +} + +fn fit(avail: Rect, doc: &Presentation) -> Rect { + let (sw, sh) = (doc.slide_size.width as f32, doc.slide_size.height as f32); + let s = (avail.width() / sw).min(avail.height() / sh); + Rect::from_center_size(avail.center(), vec2(sw * s, sh * s)) +} + +fn quad_mesh(tex: Option, rect: Rect, l: &Layer, color: Color32) -> Mesh { + let mut m = match tex { + Some(id) => Mesh::with_texture(id), + None => Mesh::default(), + }; + let a = (l.alpha.clamp(0.0, 1.0) * 255.0) as u8; + let col = if tex.is_some() { Color32::from_white_alpha(a) } else { color.gamma_multiply(l.alpha.clamp(0.0, 1.0) as f32) }; + for k in 0..4 { + let q = l.quad.get(k).copied().unwrap_or([0.0, 0.0]); + let uv = l.uv.get(k).copied().unwrap_or([0.0, 0.0]); + let p = pos2(rect.min.x + q[0] as f32 * rect.width(), rect.min.y + q[1] as f32 * rect.height()); + m.vertices.push(egui::epaint::Vertex { pos: p, uv: pos2(uv[0] as f32, uv[1] as f32), color: col }); + } + m.add_triangle(0, 1, 2); + m.add_triangle(0, 2, 3); + m +} + +pub fn ui(app: &mut SlideApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { + app.show = None; + return; + }; + let now = ctx.input(|i| i.time); + let Some(mut show) = app.show.take() else { return }; + if !show.started { + show.started = true; + show.slide_start = now; + show.step_start = now; + show.show_start = now; + show.last_move = now; + if !show.reading { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true)); + } + show.after_enter(&doc, now); + } + let mut keep = true; + // Input. + let (keys, clicked, secondary, moved) = ctx.input(|i| { + let keys: Vec<(egui::Key, egui::Modifiers)> = i + .events + .iter() + .filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None }) + .collect(); + (keys, i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0) + }); + if moved { + show.last_move = now; + } + let mut actions: Vec = vec![]; + for (k, m) in keys { + use egui::Key::*; + match k { + Escape | Minus => keep = false, + ArrowRight | ArrowDown | Space | PageDown | N => { + if show.ended { + keep = false; + } else { + actions.push(show.state.next(&doc)); + } + } + Enter => { + if let Ok(n) = show.goto.parse::() { + actions.push(show.state.goto(&doc, n.saturating_sub(1))); + show.goto.clear(); + } else if show.ended { + keep = false; + } else { + actions.push(show.state.next(&doc)); + } + } + ArrowLeft | ArrowUp | Backspace | PageUp | P if !(k == P && m.command) => { + show.ended = false; + actions.push(show.state.prev(&doc)); + } + P if m.command => show.pen = !show.pen, + A if m.command => show.pen = false, + Home => actions.push(show.state.goto(&doc, 0)), + End => actions.push(show.state.goto(&doc, doc.slides.len().saturating_sub(1))), + B | Period => show.blank = if show.blank == Some(Color32::BLACK) { None } else { Some(Color32::BLACK) }, + W | Comma => show.blank = if show.blank == Some(Color32::WHITE) { None } else { Some(Color32::WHITE) }, + E => show.strokes.clear(), + Num0 | Num1 | Num2 | Num3 | Num4 | Num5 | Num6 | Num7 | Num8 | Num9 => { + show.goto.push_str(k.name()); + } + _ => {} + } + } + if clicked && !show.pen && !show.reading_bar_hit(&ctx) { + if show.ended { + keep = false; + } else if show.blank.is_some() { + show.blank = None; + } else { + actions.push(show.state.next(&doc)); + } + } + if secondary && !show.ended { + actions.push(show.state.prev(&doc)); + } + // Step clock. + let (step, playing) = (show.state.step, show.state.playing); + let t_step = if playing { now - show.step_start } else { 0.0 }; + if playing { + let dur = show.timeline(&doc).step_duration(step); + if t_step >= dur { + actions.push(show.state.step_done(&doc)); + show.step_end = now; + } + } else if show.trans.is_none() { + // Auto-advance (After: timings) or autoplay preview. + let idle = now - show.step_end.max(show.slide_start); + let cur_step = show.state.step; + let next_auto = show.timeline(&doc).step_is_auto(cur_step); + let n_steps = show.timeline(&doc).steps(); + if show.autoplay && cur_step < n_steps && idle > 0.3 { + actions.push(show.state.next(&doc)); + } else if next_auto && cur_step < n_steps { + actions.push(show.state.next(&doc)); + } else if !show.rehearse && show.state.auto_advance_due(&doc, idle) && !show.ended { + actions.push(show.state.next(&doc)); + } else if show.preview_only && cur_step >= n_steps && idle > 0.8 { + keep = false; + } + } + for a in actions { + if !show.apply(a, &doc, now) { + keep = false; + } + } + // Draw. + let full = ui.max_rect(); + let painter = ui.painter_at(full); + painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, &doc); + let ppp = ctx.pixels_per_point(); + let size = ((srect.width() * ppp).round().max(1.0) as u32, (srect.height() * ppp).round().max(1.0) as u32); + if show.ended { + painter.text( + pos2(full.center().x, full.min.y + 40.0), + Align2::CENTER_CENTER, + "End of slide show, click to exit.", + theme::font(18.0), + Color32::from_gray(200), + ); + } else { + let idx = show.state.slide; + let step = show.state.step; + let animating = show.state.playing; + let t = if animating { now - show.step_start } else { 0.0 }; + let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize }; + let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) { + show.tex.as_ref().map(|(_, t)| t.clone()) + } else { + let tl = show.timeline(&doc).clone(); + let f = move |id: ShapeId| state_for(&tl, id, step, t); + let threads = + if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; + let img = slidecraft_render::render_slide( + &doc, + idx, + &RenderOpts { + scale: size.0 as f64 / doc.slide_size.width.max(1.0), + size: Some(size), + state: Some(&f), + threads, + ..Default::default() + }, + ); + let ci = crate::textures::to_color_image(&img, false); + let tex = match show.tex.take() { + Some((_, mut h)) => { + h.set(ci, egui::TextureOptions::LINEAR); + h + } + None => ctx.load_texture("show", ci, egui::TextureOptions::LINEAR), + }; + show.tex = Some((key, tex.clone())); + Some(tex) + }; + let mut drew = false; + if let (Some(tr), Some(new)) = (&show.trans, &tex) { + let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); + if p < 1.0 { + for l in slidecraft_anim::transition_layers(&tr.kind, &tr.option, p) { + let mesh = match l.source { + Source::Old => match &tr.old { + Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), + None => quad_mesh(None, srect, &l, Color32::BLACK), + }, + Source::New => quad_mesh(Some(new.id()), srect, &l, Color32::WHITE), + Source::Black => quad_mesh(None, srect, &l, Color32::BLACK), + Source::White => quad_mesh(None, srect, &l, Color32::WHITE), + }; + painter.add(egui::Shape::mesh(mesh)); + } + drew = true; + ctx.request_repaint(); + } + } + if !drew { + show.trans = None; + if let Some(tex) = &tex { + painter.image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + } + if animating { + ctx.request_repaint(); + } + } + if let Some(c) = show.blank { + painter.rect_filled(full, CornerRadius::ZERO, c); + } + // Pen. + let resp = ui.interact(full, ui.id().with("show_pen"), if show.pen { Sense::drag() } else { Sense::hover() }); + if show.pen { + if resp.drag_started() { + show.strokes.push(vec![]); + } + if resp.dragged() + && let (Some(p), Some(s)) = (resp.interact_pointer_pos(), show.strokes.last_mut()) + { + s.push(pos2((p.x - srect.min.x) / srect.width(), (p.y - srect.min.y) / srect.height())); + } + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + } else if now - show.last_move > 2.5 && !show.reading { + ctx.set_cursor_icon(egui::CursorIcon::None); + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + } + let pen_col = theme::to_color32(doc.show.pen_color); + for s in &show.strokes { + let pts: Vec = s.iter().map(|q| pos2(srect.min.x + q.x * srect.width(), srect.min.y + q.y * srect.height())).collect(); + painter.add(egui::Shape::line(pts, Stroke::new(3.0, pen_col))); + } + // Reading view bar / on-screen controls. + if show.reading { + let bar = Rect::from_min_max(pos2(full.min.x, full.max.y - 34.0), full.max); + painter.rect_filled(bar, CornerRadius::ZERO, Color32::from_gray(32)); + painter.text( + pos2(bar.min.x + 14.0, bar.center().y), + Align2::LEFT_CENTER, + format!("Slide {} of {}", show.state.slide + 1, doc.slides.len()), + theme::font(12.0), + Color32::from_gray(200), + ); + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(bar.max.x - 260.0, bar.min.y), bar.max)) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + if child.button("Close").clicked() { + keep = false; + } + if child.button("▶").clicked() { + let a = show.state.next(&doc); + if !show.apply(a, &doc, now) { + keep = false; + } + } + if child.button("◀").clicked() { + let a = show.state.prev(&doc); + show.apply(a, &doc, now); + } + } else if now - show.last_move < 2.5 { + let bar = Rect::from_min_size(pos2(full.min.x + 20.0, full.max.y - 50.0), vec2(250.0, 34.0)); + painter.rect_filled(bar, CornerRadius::same(17), Color32::from_black_alpha(140)); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + let b = |ui: &mut Ui, s: &str| ui.add(egui::Button::new(egui::RichText::new(s).color(Color32::WHITE).size(15.0)).frame(false)); + if b(&mut child, "◀").clicked() { + let a = show.state.prev(&doc); + show.apply(a, &doc, now); + } + if b(&mut child, "▶").clicked() { + let a = show.state.next(&doc); + if !show.apply(a, &doc, now) { + keep = false; + } + } + if b(&mut child, if show.pen { "✏ on" } else { "✏" }).clicked() { + show.pen = !show.pen; + } + if b(&mut child, "⬛").clicked() { + show.blank = Some(Color32::BLACK); + } + if b(&mut child, "End").clicked() { + keep = false; + } + ctx.request_repaint_after(std::time::Duration::from_millis(300)); + } + if show.rehearse { + let t = now - show.slide_start; + let total = now - show.show_start; + let r = Rect::from_min_size(pos2(full.min.x + 12.0, full.min.y + 12.0), vec2(220.0, 30.0)); + painter.rect_filled(r, CornerRadius::same(6), Color32::from_black_alpha(160)); + painter.text( + r.center(), + Align2::CENTER_CENTER, + format!("Slide {:02}:{:04.1} Total {:02}:{:02}", (t / 60.0) as u32, t % 60.0, (total / 60.0) as u32, (total % 60.0) as u32), + theme::font(13.0), + Color32::WHITE, + ); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + // Presenter view in a second window. + if show.presenter && !show.reading && !show.ended { + presenter_window(&ctx, &mut show, &doc, now, &mut keep); + } + if keep { + app.show = Some(show); + } else { + if !show.reading { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); + } + if show.rehearse { + if let Some((_, tl)) = &show.timeline { + let _ = tl; + } + show.timings.push((show.state.slide, now - show.slide_start)); + for (i, secs) in show.timings.clone() { + let _ = app.run("transition.timing", json!({"index": i, "after": (secs * 1000.0).round() as u64})); + } + app.set_status("Slide timings recorded."); + } + let last = show.state.slide; + let _ = app.run("slide.go", json!({"index": last})); + } +} + +impl Show { + fn reading_bar_hit(&self, ctx: &egui::Context) -> bool { + // Clicks on the on-screen controls must not also advance. + ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| { + let r = ctx.content_rect(); + (self.reading && p.y > r.max.y - 34.0) || (!self.reading && p.y > r.max.y - 56.0 && p.x < r.min.x + 280.0) + }) + } +} + +fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep: &mut bool) { + let cur = show.state.slide; + let next = slidecraft_anim::show_order(doc).into_iter().find(|i| *i > cur); + let cur_tex = show.tex.as_ref().map(|(_, t)| t.clone()); + if let Some(n) = next + && show.next_tex.as_ref().is_none_or(|(k, _)| *k != n) + { + let img = slidecraft_render::render_slide( + doc, + n, + &RenderOpts { + scale: 480.0 / doc.slide_size.width.max(1.0), + size: Some((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)), + ..Default::default() + }, + ); + show.next_tex = Some((n, ctx.load_texture("presenter-next", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))); + } + let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone()); + let notes = doc.slides.get(cur).map(|s| s.notes_text()).unwrap_or_default(); + let elapsed = now - show.show_start; + let mut actions = vec![]; + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("slidecraft_presenter"), + egui::ViewportBuilder::default().with_title("Presenter View — SlideCraft").with_inner_size([1100.0, 700.0]), + |ui, _| { + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::from_gray(24)).inner_margin(egui::Margin::same(16))).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!( + "{:02}:{:02}:{:02}", + (elapsed / 3600.0) as u32, + (elapsed / 60.0) as u32 % 60, + elapsed as u32 % 60 + )) + .size(22.0) + .color(Color32::WHITE), + ); + ui.add_space(20.0); + ui.label(egui::RichText::new(format!("Slide {} of {}", cur + 1, doc.slides.len())).size(16.0).color(Color32::from_gray(200))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("End Show").clicked() { + *keep = false; + } + if ui.button("Next ▶").clicked() { + actions.push(show.state.next(doc)); + } + if ui.button("◀ Previous").clicked() { + actions.push(show.state.prev(doc)); + } + }); + }); + ui.add_space(10.0); + let avail = ui.available_rect_before_wrap(); + let left = Rect::from_min_max(avail.min, pos2(avail.min.x + avail.width() * 0.62, avail.max.y)); + let right = Rect::from_min_max(pos2(left.max.x + 16.0, avail.min.y), avail.max); + let cr = fit(Rect::from_min_max(left.min, pos2(left.max.x, left.min.y + left.height() * 0.8)), doc); + let cr = Rect::from_min_size(left.min, cr.size()); + if let Some(t) = &cur_tex { + ui.painter().image(t.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + let nr = Rect::from_min_size( + right.min + vec2(0.0, 22.0), + vec2(right.width(), right.width() * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32), + ); + ui.painter().text(right.min, Align2::LEFT_TOP, "Next", theme::font(13.0), Color32::from_gray(170)); + match &next_tex { + Some(t) => { + ui.painter().image(t.id(), nr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + ui.painter().rect_filled(nr, CornerRadius::ZERO, Color32::from_gray(40)); + ui.painter().text(nr.center(), Align2::CENTER_CENTER, "End of slide show", theme::font(13.0), Color32::from_gray(170)); + } + } + let notes_rect = Rect::from_min_max(pos2(right.min.x, nr.max.y + 16.0), right.max); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); + egui::ScrollArea::vertical().show(&mut child, |ui| { + ui.label(egui::RichText::new(if notes.is_empty() { "No notes." } else { ¬es }).size(18.0).color(Color32::from_gray(230))); + }); + }); + if ui.input(|i| i.viewport().close_requested()) { + show.presenter = false; + } + }, + ); + for a in actions { + if !show.apply(a, doc, now) { + *keep = false; + } + } +} diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs new file mode 100644 index 0000000..a61af43 --- /dev/null +++ b/crates/ui-egui/src/sorter.rs @@ -0,0 +1,231 @@ +//! Slide Sorter, Notes Page and the Outline pane. + +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; + +use crate::SlideApp; +use crate::theme::{self, Tokens}; + +/// Grid of slides; click selects, double-click opens in Normal view, drag reorders. +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let cur = st.selection.slide; + let multi = st.selection.slides.clone(); + let zoom = app.ui.sorter_zoom.clamp(0.5, 3.0); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.add_space(16.0); + let tw = 200.0 * zoom; + let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; + let ppp = ui.ctx().pixels_per_point(); + let cols = ((ui.available_width() - 32.0) / (tw + 36.0)).floor().max(1.0) as usize; + let mut budget = 4u32; + let mut rects = vec![]; + let mut need_more = false; + for row in 0..doc.slides.len().div_ceil(cols) { + ui.horizontal(|ui| { + ui.add_space(24.0); + for c in 0..cols { + let i = row * cols + c; + if i >= doc.slides.len() { + break; + } + let (cell, resp) = ui.allocate_exact_size(vec2(tw + 36.0, th + 36.0), Sense::click_and_drag()); + let img = Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(tw, th)); + rects.push((i, cell)); + let p = ui.painter(); + match app.textures.thumb(ui.ctx(), &doc, i, (tw * ppp) as u32, &mut budget) { + Some(tex) => { + p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE); + need_more = true; + } + } + let id = doc.slides.get(i).map(|s| s.id); + let selected = i == cur || id.is_some_and(|id| multi.contains(&id)); + p.rect_stroke( + img.expand(if selected { 2.0 } else { 0.0 }), + CornerRadius::same(3), + Stroke::new(if selected { 3.0 } else { 1.0 }, if selected { t.accent } else { t.border }), + egui::StrokeKind::Outside, + ); + p.text(pos2(img.min.x, img.max.y + 12.0), Align2::LEFT_CENTER, format!("{}", i + 1), theme::font(12.0), t.text_dim); + if let Some(tr) = doc.slides.get(i).and_then(|s| s.transition.as_ref()).filter(|t| t.kind != "none") { + p.text(pos2(img.max.x, img.max.y + 12.0), Align2::RIGHT_CENTER, format!("★ {}", tr.kind), theme::font(10.5), t.text_faint); + } + if resp.clicked() { + let add = ui.input(|i| i.modifiers.command); + if add { + if let Some(id) = id { + let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true})); + } + } else { + let _ = app.run("slide.go", json!({"index": i})); + } + } + if resp.double_clicked() { + let _ = app.run("slide.go", json!({"index": i})); + app.ui.view = crate::ViewMode::Normal; + } + if resp.drag_started() { + app.thumb_drag = Some((i, i)); + } + resp.context_menu(|ui| crate::thumbs::slide_menu(app, ui, i)); + } + }); + } + if need_more { + ui.ctx().request_repaint(); + } + if let Some((from, _)) = app.thumb_drag { + if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) { + let to = + rects.iter().find(|(_, r)| r.contains(pp)).map(|(i, r)| if pp.x > r.center().x { i + 1 } else { *i }).unwrap_or(doc.slides.len()); + app.thumb_drag = Some((from, to)); + if let Some((_, r)) = rects.iter().find(|(i, _)| *i == to).or(rects.last()) { + let x = if to >= doc.slides.len() { r.max.x - 6.0 } else { r.min.x + 6.0 }; + ui.painter().line_segment([pos2(x, r.min.y + 6.0), pos2(x, r.max.y - 20.0)], Stroke::new(3.0, t.accent)); + } + } + if ui.input(|i| i.pointer.any_released()) { + if let Some((from, to)) = app.thumb_drag.take() + && to != from + && to != from + 1 + { + let _ = app.run("slide.move", json!({"from": from, "to": if to > from { to - 1 } else { to }})); + } + app.thumb_drag = None; + } + } + // Ctrl/⌘ + wheel zooms the sorter. + let (zd, cmd) = ui.input(|i| (i.zoom_delta(), i.modifiers.command)); + if (zd - 1.0).abs() > 1e-3 || cmd { + app.ui.sorter_zoom = (zoom * zd).clamp(0.5, 3.0); + } + }); +} + +/// Notes Page: the slide on top and its notes underneath, on a page. +pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let avail = ui.available_rect_before_wrap(); + ui.painter().rect_filled(avail, CornerRadius::ZERO, t.canvas); + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let idx = st.selection.slide; + let page_h = avail.height() - 40.0; + let page_w = page_h * 8.5 / 11.0; + let page = Rect::from_center_size(avail.center(), vec2(page_w, page_h)); + ui.painter().rect_filled(page.translate(vec2(2.0, 2.0)), CornerRadius::ZERO, t.shadow); + ui.painter().rect_filled(page, CornerRadius::ZERO, Color32::WHITE); + let sw = page_w * 0.78; + let sh = sw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; + let srect = Rect::from_min_size(pos2(page.center().x - sw / 2.0, page.min.y + page_h * 0.08), vec2(sw, sh)); + let ppp = ui.ctx().pixels_per_point(); + let mut budget = 2; + if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (sw * ppp) as u32, &mut budget) { + ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside); + let notes_rect = Rect::from_min_max(pos2(srect.min.x, srect.max.y + 24.0), pos2(srect.max.x, page.max.y - page_h * 0.06)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); + let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default(); + let id = egui::Id::new(("notespage", idx)); + let mut buf: String = child.data_mut(|d| d.get_temp(id).unwrap_or_else(|| current.clone())); + let r = child.add( + egui::TextEdit::multiline(&mut buf) + .hint_text("Click to add text") + .desired_width(notes_rect.width()) + .desired_rows(12) + .frame(egui::Frame::NONE) + .text_color(Color32::from_gray(30)) + .font(theme::font(14.0)), + ); + if r.lost_focus() && buf != current { + let _ = app.run("slide.notes", json!({"text": buf, "index": idx})); + } + child.data_mut(|d| d.insert_temp(id, buf)); + let nav = ui.input(|i| (i.key_pressed(egui::Key::PageDown), i.key_pressed(egui::Key::PageUp))); + if nav.0 { + let _ = app.run("slide.next", json!({})); + } + if nav.1 { + let _ = app.run("slide.previous", json!({})); + } +} + +/// Outline view: slide titles and body text as an editable outline. +pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::left("outline").resizable(true).default_size(360.0).frame(egui::Frame::NONE.fill(t.card).inner_margin(egui::Margin::same(10))).show( + ui, + |ui| { + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let cur = st.selection.slide; + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + for (i, s) in doc.slides.iter().enumerate() { + let title_shape = s.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).map(|x| x.id.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(format!("{}", i + 1)).color(t.text_dim)); + let (r, _) = ui.allocate_exact_size(vec2(14.0, 10.0), Sense::hover()); + ui.painter().rect_stroke( + r, + CornerRadius::same(1), + Stroke::new(1.0, if i == cur { t.accent } else { t.text_dim }), + egui::StrokeKind::Inside, + ); + let id = egui::Id::new(("ol-title", s.id.0)); + let cur_title = s.title(); + let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_title.clone())); + let resp = + ui.add(egui::TextEdit::singleline(&mut v).font(theme::bold(14.0)).frame(egui::Frame::NONE).desired_width(f32::INFINITY)); + if resp.gained_focus() { + let _ = app.run("slide.go", json!({"index": i})); + } + if resp.lost_focus() && v != cur_title { + if let Some(tid) = title_shape { + let _ = app.run("text.set", json!({"id": tid, "text": v})); + } + } + if !resp.has_focus() { + ui.data_mut(|d| d.remove::(id)); + } else { + ui.data_mut(|d| d.insert_temp(id, v)); + } + }); + for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) { + let Some(tb) = &sh.text else { continue }; + if tb.is_empty() { + continue; + } + let id = egui::Id::new(("ol-body", s.id.0, sh.id.0)); + let cur_text: String = + tb.paragraphs.iter().map(|p| format!("{}{}", "\t".repeat(p.level as usize), p.text())).collect::>().join("\n"); + let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_text.clone())); + ui.horizontal(|ui| { + ui.add_space(36.0); + let resp = + ui.add(egui::TextEdit::multiline(&mut v).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1)); + if resp.gained_focus() { + let _ = app.run("slide.go", json!({"index": i})); + } + if resp.lost_focus() && v != cur_text { + let _ = app.run("text.set", json!({"id": sh.id.0, "text": v})); + } + if !resp.has_focus() { + ui.data_mut(|d| d.remove::(id)); + } else { + ui.data_mut(|d| d.insert_temp(id, v.clone())); + } + }); + } + ui.add_space(6.0); + } + }); + }, + ); +} diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs new file mode 100644 index 0000000..7a7b159 --- /dev/null +++ b/crates/ui-egui/src/status.rs @@ -0,0 +1,155 @@ +//! Status bar (slide counter, language, notes/comments toggles, view buttons, zoom slider) and the +//! notes pane under the slide. + +use egui::{Align2, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; + +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; +use crate::{SlideApp, ViewMode}; + +pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::bottom("status").exact_size(28.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { + let rect = ui.max_rect(); + let Some(st) = app.session.active() else { return }; + let n = st.doc.slides.len(); + let left = match st.selection.target { + slidecraft_engine::Target::Slides => format!("Slide {} of {}", (st.selection.slide + 1).min(n.max(1)), n), + _ => "Slide Master".to_string(), + }; + let p = ui.painter(); + p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim); + p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, "English (United States)", theme::font(12.0), t.text_dim); + // Transient status message. + if let Some((msg, at)) = app.status.clone() { + if crate::now_ms() - at < 6000.0 { + p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } else { + app.status = None; + } + } + let mut right = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(rect.max.x - 560.0, rect.min.y), pos2(rect.max.x - 8.0, rect.max.y))) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + right.spacing_mut().item_spacing.x = 4.0; + if small_icon(&mut right, Icon::FitSmall, "Fit slide to current window", false).clicked() { + app.ui.zoom = None; + } + let pct = (app.ui.zoom.unwrap_or(app.canvas_scale) * 100.0).round(); + let lbl = right.add(egui::Label::new(egui::RichText::new(format!("{pct}%")).size(12.0).color(t.text_dim)).sense(Sense::click())); + if lbl.clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("zoom")); + } + if small_icon(&mut right, Icon::ZoomIn, "Zoom In", false).clicked() { + let _ = app.run("view.zoomIn", json!({})); + } + // Slider 10%–400% (log scale). + let mut z = app.ui.zoom.unwrap_or(app.canvas_scale).clamp(0.1, 4.0); + let mut lz = z.ln(); + let s = right.add_sized(vec2(110.0, 18.0), egui::Slider::new(&mut lz, (0.1f32).ln()..=(4.0f32).ln()).show_value(false)); + if s.changed() { + z = lz.exp(); + app.ui.zoom = Some(z); + } + if small_icon(&mut right, Icon::ZoomOut, "Zoom Out", false).clicked() { + let _ = app.run("view.zoomOut", json!({})); + } + right.add_space(8.0); + let view = app.ui.view; + if small_icon(&mut right, Icon::ViewShowSmall, "Slide Show", false).clicked() { + let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(i, false); + } + if small_icon(&mut right, Icon::ViewReadingSmall, "Reading View", false).clicked() { + let _ = app.run("view.reading", json!({})); + } + if small_icon(&mut right, Icon::ViewSorterSmall, "Slide Sorter", view == ViewMode::Sorter).clicked() { + app.ui.view = ViewMode::Sorter; + } + if small_icon(&mut right, Icon::ViewNormalSmall, "Normal", view == ViewMode::Normal).clicked() { + app.ui.view = ViewMode::Normal; + } + right.add_space(8.0); + let c_on = app.ui.pane.as_deref() == Some("comments"); + if labeled(&mut right, Icon::CommentsSmall, "Comments", c_on).clicked() { + let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); + } + let n_on = app.ui.notes; + if labeled(&mut right, Icon::NotesSmall, "Notes", n_on).clicked() { + app.ui.notes = !n_on; + } + let _ = Stroke::NONE; + }); +} + +fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 20.0), Sense::click()); + if on || resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), if on { t.selected } else { t.hover }); + } + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), icon, t.text_dim, false); + resp.on_hover_text(tip) +} + +fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let w = 22.0 + ui.fonts_mut(|f| f.layout_no_wrap(label.into(), theme::font(12.0), t.text).size().x) + 8.0; + let (r, resp) = ui.allocate_exact_size(vec2(w, 20.0), Sense::click()); + if on || resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), if on { t.selected } else { t.hover }); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 11.0, r.center().y), vec2(15.0, 15.0)), icon, t.text_dim, false); + ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, theme::font(12.0), t.text_dim); + resp +} + +/// The speaker notes editor under the slide. +pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + if st.selection.target != slidecraft_engine::Target::Slides { + return; + } + let idx = st.selection.slide; + let uid = st.uid; + let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default(); + egui::Panel::bottom("notes") + .resizable(true) + .default_size(app.ui.notes_height) + .size_range(36.0..=400.0) + .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(8, 6))) + .show(ui, |ui| { + app.ui.notes_height = ui.available_height() + 12.0; + let rect = ui.max_rect(); + ui.painter() + .line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator)); + // Keep a local buffer per slide; commit when it changes and the field loses focus. + if app.notes_buf.0 != uid || app.notes_buf.1 != idx { + app.notes_buf = (uid, idx, current.clone()); + } + let id = egui::Id::new("notes_edit"); + let focused = ui.ctx().memory(|m| m.has_focus(id)); + if !focused && app.notes_buf.2 != current { + app.notes_buf.2 = current.clone(); + } + let mut buf = app.notes_buf.2.clone(); + let r = ui.add( + egui::TextEdit::multiline(&mut buf) + .id(id) + .hint_text("Click to add notes") + .frame(egui::Frame::NONE) + .desired_width(f32::INFINITY) + .desired_rows(2) + .font(theme::font(13.0)), + ); + app.notes_buf.2 = buf.clone(); + if r.lost_focus() && buf != current { + let _ = app.run("slide.notes", json!({"text": buf, "index": idx})); + } + }); +} diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs new file mode 100644 index 0000000..c77a452 --- /dev/null +++ b/crates/ui-egui/src/textures.rs @@ -0,0 +1,150 @@ +//! Rendered slides as GPU textures: the canvas slide and thumbnails, cached by the slide's and its +//! master's `Arc` identity (an unchanged slide keeps its `Arc`, so it never re-renders). + +use std::collections::HashMap; +use std::sync::Arc; + +use egui::{ColorImage, TextureHandle, TextureOptions}; +use slidecraft_model::{Master, Presentation, Slide}; +use slidecraft_render::{Image, RenderOpts}; + +#[derive(Clone, PartialEq)] +struct Key { + slide: usize, + master: usize, + size: (u32, u32), + edit: bool, + extra: u64, +} + +struct Entry { + key: Key, + tex: TextureHandle, + // Keep the Arcs alive so their addresses can't be reused while cached. + _slide: Arc, + _master: Option>, + stamp: u64, +} + +#[derive(Default)] +pub struct Textures { + canvas: Option, + thumbs: HashMap, + clock: u64, + pub last_render_ms: f64, +} + +pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage { + let w = img.width as usize; + let h = img.height as usize; + let mut px = Vec::with_capacity(w * h); + for c in img.pixels.chunks_exact(4) { + let (r, g, b, a) = (c[0], c[1], c[2], c[3]); + if grayscale { + let y = ((r as u32 * 77 + g as u32 * 150 + b as u32 * 29) >> 8) as u8; + px.push(egui::Color32::from_rgba_premultiplied(y, y, y, a)); + } else { + px.push(egui::Color32::from_rgba_premultiplied(r, g, b, a)); + } + } + px.resize(w * h, egui::Color32::TRANSPARENT); + ColorImage::new([w, h], px) +} + +fn master_of(p: &Presentation, slide: &Slide) -> Option> { + p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned() +} + +fn threads() -> u16 { + if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) } +} + +impl Textures { + /// The canvas texture for slide `index` at `size` pixels. + pub fn slide( + &mut self, + ctx: &egui::Context, + p: &Presentation, + index: usize, + size: (u32, u32), + edit: bool, + extra: u64, + grayscale: bool, + ) -> Option { + let slide = p.slides.get(index)?.clone(); + let master = master_of(p, &slide); + let key = Key { + slide: Arc::as_ptr(&slide) as usize, + master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0), + size, + edit, + extra: extra ^ grayscale as u64, + }; + if let Some(e) = &self.canvas + && e.key == key + { + return Some(e.tex.clone()); + } + let t0 = crate::now_ms(); + let scale = size.0 as f64 / p.slide_size.width.max(1.0); + let img = slidecraft_render::render_slide(p, index, &RenderOpts { scale, edit, threads: threads(), size: Some(size), ..Default::default() }); + self.last_render_ms = crate::now_ms() - t0; + let ci = to_color_image(&img, grayscale); + let tex = match self.canvas.take() { + Some(mut e) => { + e.tex.set(ci, TextureOptions::LINEAR); + e.tex + } + None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR), + }; + self.canvas = Some(Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: 0 }); + Some(tex) + } + + /// Force the canvas to re-render next time (fonts loaded, theme changed in place…). + pub fn invalidate(&mut self) { + self.canvas = None; + self.thumbs.clear(); + } + + /// A thumbnail texture; renders at most `budget` new thumbnails per frame (returns None when + /// it is over budget, so the caller draws a placeholder and asks for another frame). + pub fn thumb(&mut self, ctx: &egui::Context, p: &Presentation, index: usize, width_px: u32, budget: &mut u32) -> Option { + let slide = p.slides.get(index)?.clone(); + let master = master_of(p, &slide); + let h = (width_px as f64 * p.slide_size.height / p.slide_size.width.max(1.0)).round().max(1.0) as u32; + let key = Key { + slide: Arc::as_ptr(&slide) as usize, + master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0), + size: (width_px, h), + edit: false, + extra: 0, + }; + self.clock += 1; + let clock = self.clock; + if let Some(e) = self.thumbs.get_mut(&key.slide) + && e.key == key + { + e.stamp = clock; + return Some(e.tex.clone()); + } + if *budget == 0 { + // Keep showing the stale thumbnail while the new one waits. + return self.thumbs.get(&key.slide).map(|e| e.tex.clone()); + } + *budget -= 1; + let scale = width_px as f64 / p.slide_size.width.max(1.0); + let img = slidecraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() }); + let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR); + // Drop the old entry for this slide address; entries for slides that no longer exist age out. + self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: clock }); + if self.thumbs.len() > 600 { + let mut v: Vec<(usize, u64)> = self.thumbs.iter().map(|(k, e)| (*k, e.stamp)).collect(); + v.sort_by_key(|x| x.1); + for (k, _) in v.into_iter().take(200) { + self.thumbs.remove(&k); + } + } + Some(tex) + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs new file mode 100644 index 0000000..b45eb53 --- /dev/null +++ b/crates/ui-egui/src/theme.rs @@ -0,0 +1,194 @@ +//! Design tokens (light, PowerPoint-like; and dark) and fonts for the UI. + +use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, Stroke, Visuals}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Brightness { + #[default] + Light, + Dark, +} + +/// Every colour the chrome uses. +#[derive(Clone, Copy, Debug)] +pub struct Tokens { + pub dark: bool, + /// Window background (title bar, tab row, side panes). + pub chrome: Color32, + /// The ribbon card and popovers. + pub card: Color32, + pub card_border: Color32, + /// Canvas around the slide. + pub canvas: Color32, + pub text: Color32, + pub text_dim: Color32, + pub text_faint: Color32, + pub border: Color32, + pub separator: Color32, + pub hover: Color32, + pub pressed: Color32, + pub selected: Color32, + /// SlideCraft's accent (tabs underline, selected thumbnail, primary buttons). + pub accent: Color32, + pub accent_text: Color32, + pub contextual: Color32, + pub selection: Color32, + pub handle_fill: Color32, + pub handle_stroke: Color32, + pub guide: Color32, + pub caret: Color32, + pub text_selection: Color32, + pub field_bg: Color32, + pub shadow: Color32, +} + +impl Tokens { + pub fn light() -> Self { + Tokens { + dark: false, + chrome: Color32::from_rgb(0xF5, 0xF5, 0xF5), + card: Color32::WHITE, + card_border: Color32::from_rgb(0xE3, 0xE3, 0xE3), + canvas: Color32::from_rgb(0xF0, 0xF0, 0xF0), + text: Color32::from_rgb(0x24, 0x24, 0x24), + text_dim: Color32::from_rgb(0x5C, 0x5C, 0x5C), + text_faint: Color32::from_rgb(0x99, 0x99, 0x99), + border: Color32::from_rgb(0xD6, 0xD6, 0xD6), + separator: Color32::from_rgb(0xE1, 0xE1, 0xE1), + hover: Color32::from_rgb(0xEB, 0xEB, 0xEB), + pressed: Color32::from_rgb(0xDE, 0xDE, 0xDE), + selected: Color32::from_rgb(0xE6, 0xE6, 0xE6), + accent: Color32::from_rgb(0xD9, 0x5A, 0x1A), + accent_text: Color32::WHITE, + contextual: Color32::from_rgb(0xC2, 0x4E, 0x14), + selection: Color32::from_rgb(0x7A, 0x7A, 0x7A), + handle_fill: Color32::WHITE, + handle_stroke: Color32::from_rgb(0x6E, 0x6E, 0x6E), + guide: Color32::from_rgb(0xE8, 0x5D, 0x2A), + caret: Color32::BLACK, + text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90), + field_bg: Color32::WHITE, + shadow: Color32::from_black_alpha(28), + } + } + pub fn dark() -> Self { + Tokens { + dark: true, + chrome: Color32::from_rgb(0x26, 0x26, 0x26), + card: Color32::from_rgb(0x33, 0x33, 0x33), + card_border: Color32::from_rgb(0x44, 0x44, 0x44), + canvas: Color32::from_rgb(0x1E, 0x1E, 0x1E), + text: Color32::from_rgb(0xEE, 0xEE, 0xEE), + text_dim: Color32::from_rgb(0xBB, 0xBB, 0xBB), + text_faint: Color32::from_rgb(0x88, 0x88, 0x88), + border: Color32::from_rgb(0x4A, 0x4A, 0x4A), + separator: Color32::from_rgb(0x48, 0x48, 0x48), + hover: Color32::from_rgb(0x40, 0x40, 0x40), + pressed: Color32::from_rgb(0x4C, 0x4C, 0x4C), + selected: Color32::from_rgb(0x47, 0x47, 0x47), + accent: Color32::from_rgb(0xF2, 0x6B, 0x1D), + accent_text: Color32::WHITE, + contextual: Color32::from_rgb(0xF5, 0x8A, 0x4B), + selection: Color32::from_rgb(0xAA, 0xAA, 0xAA), + handle_fill: Color32::WHITE, + handle_stroke: Color32::from_rgb(0x6E, 0x6E, 0x6E), + guide: Color32::from_rgb(0xF2, 0x8A, 0x4B), + caret: Color32::BLACK, + text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90), + field_bg: Color32::from_rgb(0x2A, 0x2A, 0x2A), + shadow: Color32::from_black_alpha(90), + } + } + pub fn for_brightness(b: Brightness) -> Self { + match b { + Brightness::Light => Self::light(), + Brightness::Dark => Self::dark(), + } + } + pub fn get(ctx: &egui::Context) -> Self { + if ctx.global_style().visuals.dark_mode { Self::dark() } else { Self::light() } + } +} + +/// Apply tokens to egui's style. +pub fn apply(ctx: &egui::Context, t: &Tokens) { + let mut v = if t.dark { Visuals::dark() } else { Visuals::light() }; + v.panel_fill = t.chrome; + v.window_fill = t.card; + v.extreme_bg_color = t.field_bg; + v.faint_bg_color = t.hover; + v.window_stroke = Stroke::new(1.0, t.card_border); + v.window_corner_radius = CornerRadius::same(10); + v.menu_corner_radius = CornerRadius::same(8); + v.selection.bg_fill = t.accent.gamma_multiply(0.85); + v.selection.stroke = Stroke::new(1.0, t.accent_text); + v.hyperlink_color = t.accent; + v.override_text_color = Some(t.text); + for w in [&mut v.widgets.noninteractive, &mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open] { + w.corner_radius = CornerRadius::same(5); + } + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.separator); + v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + v.widgets.inactive.bg_fill = t.field_bg; + v.widgets.inactive.bg_stroke = Stroke::NONE; + v.widgets.hovered.weak_bg_fill = t.hover; + v.widgets.hovered.bg_fill = t.hover; + v.widgets.hovered.bg_stroke = Stroke::NONE; + v.widgets.active.weak_bg_fill = t.pressed; + v.widgets.active.bg_fill = t.pressed; + v.widgets.open.weak_bg_fill = t.pressed; + v.window_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 18, spread: 0, color: t.shadow }; + v.popup_shadow = egui::epaint::Shadow { offset: [0, 3], blur: 12, spread: 0, color: t.shadow }; + ctx.set_visuals(v); + ctx.global_style_mut(|s| { + s.spacing.item_spacing = egui::vec2(6.0, 4.0); + s.spacing.button_padding = egui::vec2(6.0, 3.0); + s.spacing.interact_size.y = 22.0; + s.text_styles.insert(egui::TextStyle::Body, egui::FontId::proportional(13.0)); + s.text_styles.insert(egui::TextStyle::Button, egui::FontId::proportional(13.0)); + s.text_styles.insert(egui::TextStyle::Small, egui::FontId::proportional(11.0)); + s.text_styles.insert(egui::TextStyle::Heading, egui::FontId::proportional(18.0)); + }); +} + +/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's. +pub fn install_fonts(ctx: &egui::Context) { + let mut defs = FontDefinitions::default(); + let mut names = vec![]; + for (i, f) in slidecraft_fonts::CRAFT_FONTS.iter().enumerate() { + let wanted = (f.family == "Inter" && (f.style == "Regular" || f.style == "SemiBold" || f.style == "Bold")) + || (f.family == "BIZ UDPGothic" && f.style == "Regular") + || (f.family == "Noto Sans Arabic"); + if !wanted { + continue; + } + let name = format!("craft-{i}-{}-{}", f.family, f.style); + defs.font_data.insert(name.clone(), std::sync::Arc::new(FontData::from_static(f.bytes))); + names.push((f.family, f.style, name)); + } + let regular: Vec = names.iter().filter(|(fam, st, _)| !(*fam == "Inter" && *st != "Regular")).map(|(_, _, n)| n.clone()).collect(); + if let Some(list) = defs.families.get_mut(&FontFamily::Proportional) { + for (k, n) in regular.iter().enumerate() { + list.insert(k, n.clone()); + } + } + // A bold family for headings and selected tabs. + let mut bold: Vec = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect(); + bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default()); + defs.families.insert(FontFamily::Name("bold".into()), bold); + ctx.set_fonts(defs); +} + +pub fn bold(size: f32) -> egui::FontId { + egui::FontId::new(size, FontFamily::Name("bold".into())) +} + +pub fn font(size: f32) -> egui::FontId { + egui::FontId::proportional(size) +} + +pub fn to_color32(c: slidecraft_color::Rgba) -> Color32 { + Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a) +} diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs new file mode 100644 index 0000000..977d61c --- /dev/null +++ b/crates/ui-egui/src/thumbs.rs @@ -0,0 +1,314 @@ +//! The slide thumbnail pane (Normal view): numbered thumbnails grouped by section, selection, +//! drag-to-reorder, and the slide context menu. + +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; +use slidecraft_engine::Target; + +use crate::SlideApp; +use crate::theme::{self, Tokens}; + +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides); + egui::Panel::left("thumbs") + .resizable(true) + .default_size(app.ui.thumbs_width) + .size_range(150.0..=420.0) + .frame(egui::Frame::NONE.fill(t.chrome)) + .show(ui, |ui| { + app.ui.thumbs_width = ui.available_width(); + if in_master { + master_list(app, ui); + return; + } + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.add_space(8.0); + list(app, ui); + ui.add_space(20.0); + }); + }); +} + +fn list(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let cur = st.selection.slide; + let multi = st.selection.slides.clone(); + let width = ui.available_width(); + let tw = (width - 40.0).max(60.0); + let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; + let ppp = ui.ctx().pixels_per_point(); + let mut budget = 3u32; + let mut rects = vec![]; + let section_starts: Vec<(usize, usize)> = + doc.sections.iter().enumerate().filter_map(|(si, s)| s.slides.first().and_then(|id| doc.slide_index(*id)).map(|i| (i, si))).collect(); + let mut need_more = false; + for i in 0..doc.slides.len() { + if let Some((_, si)) = section_starts.iter().find(|(start, _)| *start == i) + && let Some(sec) = doc.sections.get(*si) + { + ui.add_space(4.0); + let (r, resp) = ui.allocate_exact_size(vec2(width, 22.0), Sense::click()); + let p = ui.painter(); + crate::widgets::chevron(p, pos2(r.min.x + 14.0, r.center().y), 3.0, t.text_dim); + p.text( + pos2(r.min.x + 24.0, r.center().y), + Align2::LEFT_CENTER, + format!("{} ({})", sec.name, sec.slides.len()), + theme::font(12.0), + t.text_dim, + ); + resp.context_menu(|ui| { + if ui.button("Rename Section…").clicked() { + let mut d = crate::dialogs::Dialog::new("renameSection"); + d.params = json!({"index": si, "name": sec.name}); + app.dialog = Some(d); + ui.close(); + } + if ui.button("Remove Section").clicked() { + let _ = app.run("section.remove", json!({"index": si})); + ui.close(); + } + if ui.button("Remove Section & Slides").clicked() { + let _ = app.run("section.remove", json!({"index": si, "slides": true})); + ui.close(); + } + if ui.button("Move Section Up").clicked() { + let _ = app.run("section.move", json!({"index": si, "to": si.saturating_sub(1)})); + ui.close(); + } + if ui.button("Move Section Down").clicked() { + let _ = app.run("section.move", json!({"index": si, "to": si + 1})); + ui.close(); + } + }); + } + let (row, resp) = ui.allocate_exact_size(vec2(width, th + 16.0), Sense::click_and_drag()); + let img = Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th)); + rects.push((i, row)); + let p = ui.painter(); + let id = doc.slides.get(i).map(|s| s.id); + let selected = i == cur || id.is_some_and(|id| multi.contains(&id)); + let hidden = doc.slides.get(i).is_some_and(|s| s.hidden); + p.text( + pos2(row.min.x + 14.0, img.min.y + 2.0), + Align2::CENTER_TOP, + format!("{}", i + 1), + theme::font(12.0), + if selected { t.text } else { t.text_dim }, + ); + if hidden { + p.line_segment([pos2(row.min.x + 8.0, img.min.y + 18.0), pos2(row.min.x + 20.0, img.min.y + 6.0)], Stroke::new(1.0, t.text_dim)); + } + let has_anim = doc.slides.get(i).is_some_and(|s| !s.animations.is_empty() || s.transition.as_ref().is_some_and(|tr| tr.kind != "none")); + if has_anim { + p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint); + } + let wpx = (tw * ppp).round() as u32; + match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) { + Some(tex) => { + p.image( + tex.id(), + img, + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + if hidden { Color32::from_white_alpha(140) } else { Color32::WHITE }, + ); + } + None => { + p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE); + need_more = true; + } + } + if selected { + p.rect_stroke(img.expand(2.0), CornerRadius::same(4), Stroke::new(3.0, t.accent), egui::StrokeKind::Outside); + } else { + p.rect_stroke( + img, + CornerRadius::ZERO, + Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.text_faint } else { t.border }), + egui::StrokeKind::Outside, + ); + } + if resp.clicked() { + let (shift, cmd) = ui.input(|i| (i.modifiers.shift, i.modifiers.command)); + if let Some(id) = id { + if cmd { + let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true})); + } else if shift { + let (a, b) = (cur.min(i), cur.max(i)); + let ids: Vec = doc.slides.iter().skip(a).take(b - a + 1).map(|s| s.id.0).collect(); + let _ = app.run("slide.selectSlides", json!({"ids": ids})); + let _ = app.run("slide.go", json!({"index": i})); + let _ = app.run("slide.selectSlides", json!({"ids": ids})); + } else { + let _ = app.run("slide.go", json!({"index": i})); + } + } + } + if resp.double_clicked() { + app.start_show(i, false); + } + if resp.drag_started() { + app.thumb_drag = Some((i, i)); + } + resp.context_menu(|ui| slide_menu(app, ui, i)); + } + if need_more { + ui.ctx().request_repaint(); + } + // Drag reorder: insertion line. + if let Some((from, _)) = app.thumb_drag { + if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) { + let mut to = doc.slides.len(); + for (i, r) in &rects { + if pp.y < r.center().y { + to = *i; + break; + } + } + app.thumb_drag = Some((from, to)); + let y = rects.iter().find(|(i, _)| *i == to).map(|(_, r)| r.min.y + 2.0).or_else(|| rects.last().map(|(_, r)| r.max.y)).unwrap_or(0.0); + ui.painter().line_segment([pos2(26.0, y), pos2(width, y)], Stroke::new(2.0, t.accent)); + } + if ui.input(|i| i.pointer.any_released()) { + if let Some((from, to)) = app.thumb_drag.take() + && to != from + && to != from + 1 + { + let dest = if to > from { to - 1 } else { to }; + let _ = app.run("slide.move", json!({"from": from, "to": dest})); + } + app.thumb_drag = None; + } + } + // Click on empty space below: new slide prompt. + let (r, resp) = ui.allocate_exact_size(vec2(width, 40.0), Sense::click()); + if resp.hovered() { + ui.painter().text(r.center(), Align2::CENTER_CENTER, "+ New Slide", theme::font(12.0), t.text_faint); + } + if resp.clicked() { + let _ = app.run("slide.new", json!({})); + } +} + +pub fn slide_menu(app: &mut SlideApp, ui: &mut Ui, i: usize) { + if app.session.active().is_some_and(|d| d.selection.slide != i && !d.selection.slides.iter().any(|id| d.doc.slide_index(*id) == Some(i))) { + let _ = app.run("slide.go", json!({"index": i})); + } + for (l, id, p) in [ + ("Cut", "edit.cut", json!({"scope": "slides"})), + ("Copy", "edit.copy", json!({"scope": "slides"})), + ("Paste", "edit.paste", json!({"scope": "slides"})), + ] { + if ui.button(l).clicked() { + let _ = app.run(id, p); + ui.close(); + } + } + ui.separator(); + if ui.button("New Slide").clicked() { + let _ = app.run("slide.new", json!({})); + ui.close(); + } + if ui.button("Duplicate Slide").clicked() { + let _ = app.run("slide.duplicate", json!({})); + ui.close(); + } + if ui.button("Delete Slide").clicked() { + let _ = app.run("slide.delete", json!({})); + ui.close(); + } + ui.separator(); + if ui.button("Add Section").clicked() { + let _ = app.run("section.add", json!({"at": i})); + ui.close(); + } + ui.menu_button("Layout", |ui| { + let layouts: Vec<(String, u32)> = app + .session + .active() + .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect())) + .unwrap_or_default(); + for (n, id) in layouts { + if ui.button(n).clicked() { + let _ = app.run("slide.layout", json!({"layout": id})); + ui.close(); + } + } + }); + if ui.button("Reset Slide").clicked() { + let _ = app.run("slide.reset", json!({})); + ui.close(); + } + if ui.button("Format Background…").clicked() { + let _ = app.run("view.pane", json!({"pane": "background"})); + ui.close(); + } + let hidden = app.session.active().and_then(|d| d.doc.slides.get(i).map(|s| s.hidden)).unwrap_or(false); + if ui.button(if hidden { "Show Slide" } else { "Hide Slide" }).clicked() { + let _ = app.run("slide.hide", json!({"index": i})); + ui.close(); + } +} + +fn master_list(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let target = st.selection.target; + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.add_space(8.0); + let width = ui.available_width(); + for (mi, m) in doc.masters.iter().enumerate() { + let items: Vec> = std::iter::once(None).chain((0..m.layouts.len()).map(Some)).collect(); + for li in items { + let indent = if li.is_some() { 30.0 } else { 14.0 }; + let tw = (width - indent - 14.0).max(40.0) * if li.is_some() { 0.8 } else { 1.0 }; + let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; + let (row, resp) = ui.allocate_exact_size(vec2(width, th + 12.0), Sense::click()); + let img = Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)); + let ppp = ui.ctx().pixels_per_point(); + let px = ((tw * ppp) as u32).max(1); + let pic = slidecraft_render::render_layout( + &doc, + mi, + li, + &slidecraft_render::RenderOpts { + scale: px as f64 / doc.slide_size.width.max(1.0), + edit: true, + size: Some((px, (px as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)), + ..Default::default() + }, + ); + let tex = + ui.ctx().load_texture(format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false), egui::TextureOptions::LINEAR); + ui.painter().image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let selected = match (target, li) { + (Target::Master { master }, None) => master == mi, + (Target::Layout { master, layout }, Some(l)) => master == mi && layout == l, + _ => false, + }; + ui.painter().rect_stroke( + img.expand(if selected { 2.0 } else { 0.0 }), + CornerRadius::same(2), + Stroke::new(if selected { 3.0 } else { 1.0 }, if selected { t.accent } else { t.border }), + egui::StrokeKind::Outside, + ); + let name = match li { + Some(l) => m.layouts.get(l).map(|x| x.name.clone()).unwrap_or_default(), + None => format!("{} Slide Master", m.name), + }; + let resp = resp.on_hover_text(name); + if resp.clicked() { + let p = match li { + Some(l) => json!({"master": mi, "layout": l}), + None => json!({"master": mi}), + }; + let _ = app.run("view.slideMaster", p); + } + } + } + }); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs new file mode 100644 index 0000000..12698ba --- /dev/null +++ b/crates/ui-egui/src/widgets.rs @@ -0,0 +1,433 @@ +//! Ribbon widgets: large and small buttons, split buttons, groups, dropdowns, spinners, the colour +//! picker and galleries. + +use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, Ui, Vec2, pos2, vec2}; +use slidecraft_color::{ColorScheme, Rgba, SchemeSlot}; +use slidecraft_model::ColorRef; + +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; + +/// Height of the ribbon's content area. +pub const RIBBON_H: f32 = 66.0; + +fn bg(ui: &Ui, r: &Response, selected: bool, t: &Tokens) -> Option { + if !r.sense.senses_click() && !r.sense.senses_drag() { + return None; + } + if selected { + Some(t.selected) + } else if r.is_pointer_button_down_on() { + Some(t.pressed) + } else if r.hovered() && ui.is_enabled() { + Some(t.hover) + } else { + None + } +} + +/// A large ribbon button: 32 px icon over a one- or two-line label. +pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> Response { + big_button_ex(ui, icon, label, enabled, false, false).0 +} + +/// Large button; with `split`, the label row is a separate dropdown target (returns its response). +pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: bool, selected: bool) -> (Response, Option) { + let t = Tokens::get(ui.ctx()); + let font = theme::font(11.0); + let lines: Vec<&str> = wrap_label(label); + let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max); + let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0); + let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() }); + let painter = ui.painter_at(rect.expand(1.0)); + let (main_rect, arrow_rect) = if split { + (Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max))) + } else { + (rect, None) + }; + let main_resp = + if split { ui.interact(main_rect, resp.id.with("main"), if enabled { Sense::click() } else { Sense::hover() }) } else { resp.clone() }; + let arrow_resp = arrow_rect.map(|r| ui.interact(r, resp.id.with("arrow"), if enabled { Sense::click() } else { Sense::hover() })); + if let Some(c) = bg(ui, &main_resp, selected, &t) { + painter.rect_filled(if split { main_rect } else { rect }, CornerRadius::same(4), c); + } + if let (Some(ar), Some(a)) = (arrow_rect, &arrow_resp) + && let Some(c) = bg(ui, a, false, &t) + { + painter.rect_filled(ar, CornerRadius::same(4), c); + } + let icon_rect = Rect::from_center_size(pos2(rect.center().x, rect.min.y + 19.0), vec2(30.0, 30.0)); + icons::paint(&painter, icon_rect, icon, t.text, !enabled); + let col = if enabled { t.text } else { t.text_faint }; + let mut y = rect.min.y + 39.0; + for (i, l) in lines.iter().enumerate() { + let last = i + 1 == lines.len(); + let label_w = ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), col).size().x); + let extra = if split && last { 9.0 } else { 0.0 }; + let x = rect.center().x - (label_w + extra) / 2.0; + painter.text(pos2(x, y), Align2::LEFT_TOP, l, font.clone(), col); + if split && last { + chevron(&painter, pos2(x + label_w + 5.0, y + 7.0), 3.0, col); + } + y += 12.5; + } + (if split { main_resp } else { resp }, arrow_resp) +} + +fn wrap_label(label: &str) -> Vec<&str> { + if let Some((a, b)) = label.split_once('\n') { + return vec![a, b]; + } + let words: Vec<&str> = label.split(' ').collect(); + if words.len() >= 2 && label.chars().count() > 8 { + // Break near the middle. + let mut best = 1; + let mut best_diff = usize::MAX; + for k in 1..words.len() { + let a: usize = words.iter().take(k).map(|w| w.chars().count() + 1).sum(); + let b: usize = words.iter().skip(k).map(|w| w.chars().count() + 1).sum(); + let d = a.abs_diff(b); + if d < best_diff { + best_diff = d; + best = k; + } + } + let split_at: usize = words.iter().take(best).map(|w| w.len() + 1).sum::().saturating_sub(1); + if let (Some(a), Some(b)) = (label.get(..split_at), label.get(split_at + 1..)) { + return vec![a, b]; + } + } + vec![label] +} + +pub fn chevron(p: &egui::Painter, c: egui::Pos2, s: f32, col: Color32) { + p.line_segment([pos2(c.x - s, c.y - s * 0.5), pos2(c.x, c.y + s * 0.5)], Stroke::new(1.2, col)); + p.line_segment([pos2(c.x, c.y + s * 0.5), pos2(c.x + s, c.y - s * 0.5)], Stroke::new(1.2, col)); +} + +/// A small ribbon button: 16 px icon + label in one row (label may be empty). +pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selected: bool) -> Response { + small_button_ex(ui, icon, label, enabled, selected, false).0 +} + +pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selected: bool, split: bool) -> (Response, Option) { + let t = Tokens::get(ui.ctx()); + let font = theme::font(12.0); + let tw = if label.is_empty() { 0.0 } else { ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0 }; + let arrow_w = if split { 12.0 } else { 0.0 }; + let w = 24.0 + tw + arrow_w; + let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), if enabled { Sense::click() } else { Sense::hover() }); + let painter = ui.painter_at(rect); + let main_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - arrow_w, rect.max.y)); + let main_resp = + if split { ui.interact(main_rect, resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() }) } else { resp.clone() }; + let arrow_resp = split.then(|| { + ui.interact( + Rect::from_min_max(pos2(rect.max.x - arrow_w, rect.min.y), rect.max), + resp.id.with("a"), + if enabled { Sense::click() } else { Sense::hover() }, + ) + }); + if let Some(c) = bg(ui, &main_resp, selected, &t) { + painter.rect_filled(main_rect, CornerRadius::same(4), c); + } + if let Some(a) = &arrow_resp + && let Some(c) = bg(ui, a, false, &t) + { + painter.rect_filled(Rect::from_min_max(pos2(rect.max.x - arrow_w, rect.min.y), rect.max), CornerRadius::same(4), c); + } + icons::paint(&painter, Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(17.0, 17.0)), icon, t.text, !enabled); + if !label.is_empty() { + painter.text(pos2(rect.min.x + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, if enabled { t.text } else { t.text_faint }); + } + if split { + chevron(&painter, pos2(rect.max.x - 6.0, rect.center().y), 2.8, t.text_dim); + } + (if split { main_resp } else { resp }, arrow_resp) +} + +/// A square icon toggle (B, I, U, alignment…). +pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); + let painter = ui.painter_at(rect); + if let Some(c) = bg(ui, &resp, on, &t) { + painter.rect_filled(rect, CornerRadius::same(4), c); + } + icons::paint(&painter, rect.shrink(3.0), icon, t.text, !enabled); + resp.on_hover_text(tip) +} + +/// Icon with a colour bar under it and a dropdown arrow (Font Color, Highlight, Shape Fill…). +pub fn color_split(ui: &mut Ui, icon: Icon, tip: &str, color: Color32, enabled: bool) -> (Response, Response) { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::hover()); + let main = ui.interact(Rect::from_min_size(rect.min, vec2(23.0, 22.0)), resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() }); + let arrow = ui.interact( + Rect::from_min_max(pos2(rect.min.x + 23.0, rect.min.y), rect.max), + resp.id.with("a"), + if enabled { Sense::click() } else { Sense::hover() }, + ); + let painter = ui.painter_at(rect); + if let Some(c) = bg(ui, &main, false, &t) { + painter.rect_filled(main.rect, CornerRadius::same(4), c); + } + if let Some(c) = bg(ui, &arrow, false, &t) { + painter.rect_filled(arrow.rect, CornerRadius::same(4), c); + } + icons::paint(&painter, Rect::from_min_size(rect.min + vec2(3.0, 1.0), vec2(17.0, 15.0)), icon, t.text, !enabled); + painter.rect_filled( + Rect::from_min_size(rect.min + vec2(4.0, 17.0), vec2(15.0, 3.5)), + CornerRadius::ZERO, + if enabled { color } else { t.text_faint }, + ); + chevron(&painter, pos2(rect.max.x - 5.5, rect.center().y), 2.6, t.text_dim); + (main.on_hover_text(tip), arrow) +} + +/// A ribbon group: contents, then a thin vertical separator. +pub fn group(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + let r = ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 1.0); + add(ui) + }); + separator(ui); + r.inner +} + +pub fn separator(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(13.0, RIBBON_H - 6.0), Sense::hover()); + ui.painter().line_segment([pos2(rect.center().x, rect.min.y + 6.0), pos2(rect.center().x, rect.max.y - 4.0)], Stroke::new(1.0, t.separator)); +} + +/// Two or three rows of small controls stacked in one ribbon column. +pub fn rows(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 0.0); + ui.add_space(0.0); + add(ui) + }) + .inner +} + +/// A text dropdown field (font name, size): shows `current`, opens a popup with `add_items`. +pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 22.0), if enabled { Sense::click() } else { Sense::hover() }); + let painter = ui.painter_at(rect); + painter.rect( + rect, + CornerRadius::same(4), + t.field_bg, + Stroke::new(1.0, if resp.hovered() { t.text_faint } else { t.border }), + egui::StrokeKind::Inside, + ); + let clip = rect.shrink2(vec2(6.0, 0.0)); + painter.with_clip_rect(Rect::from_min_max(clip.min, pos2(clip.max.x - 12.0, clip.max.y))).text( + pos2(rect.min.x + 6.0, rect.center().y), + Align2::LEFT_CENTER, + current, + theme::font(12.0), + if enabled { t.text } else { t.text_faint }, + ); + chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim); + let _ = id; + egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items); + }); + resp +} + +/// The colour grid popup body: theme colours with tints/shades, standard colours, extra entries. +/// Returns the picked colour (`Some(None)` = "No Fill"/"Automatic" when `none_label` is given). +pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) -> Option> { + let t = Tokens::get(ui.ctx()); + let mut out = None; + ui.set_min_width(220.0); + ui.label(egui::RichText::new("Theme Colors").font(theme::bold(12.0))); + let slots = [ + SchemeSlot::Lt1, + SchemeSlot::Dk1, + SchemeSlot::Lt2, + SchemeSlot::Dk2, + SchemeSlot::Accent1, + SchemeSlot::Accent2, + SchemeSlot::Accent3, + SchemeSlot::Accent4, + SchemeSlot::Accent5, + SchemeSlot::Accent6, + ]; + let cell = 18.0; + let sw = |ui: &mut Ui, c: Rgba, tip: &str| -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); + ui.painter().rect( + r.shrink(1.0), + CornerRadius::same(2), + theme::to_color32(c), + Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), + egui::StrokeKind::Inside, + ); + resp.on_hover_text(tip).clicked() + }; + egui::Grid::new("theme_colors").spacing(vec2(3.0, 1.0)).show(ui, |ui| { + for slot in slots { + let alias = match slot { + SchemeSlot::Lt1 => SchemeSlot::Bg1, + SchemeSlot::Dk1 => SchemeSlot::Tx1, + SchemeSlot::Lt2 => SchemeSlot::Bg2, + SchemeSlot::Dk2 => SchemeSlot::Tx2, + s => s, + }; + if sw(ui, scheme.get(slot), slot.label()) { + out = Some(Some(ColorRef::scheme(alias))); + } + } + ui.end_row(); + for row in 0..5 { + for slot in slots { + let base = scheme.get(slot); + let vars = slidecraft_color::theme_variants(base); + let Some((mods, label)) = vars.get(row) else { continue }; + let c = slidecraft_color::apply(base, mods); + if sw(ui, c, label) { + let alias = match slot { + SchemeSlot::Lt1 => SchemeSlot::Bg1, + SchemeSlot::Dk1 => SchemeSlot::Tx1, + SchemeSlot::Lt2 => SchemeSlot::Bg2, + SchemeSlot::Dk2 => SchemeSlot::Tx2, + s => s, + }; + let mut r = ColorRef::scheme(alias); + r.mods = mods.clone(); + out = Some(Some(r)); + } + } + ui.end_row(); + } + }); + ui.add_space(4.0); + ui.label(egui::RichText::new("Standard Colors").font(theme::bold(12.0))); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + for (c, name) in slidecraft_color::STANDARD_COLORS { + if sw(ui, c, name) { + out = Some(Some(ColorRef::rgb(c))); + } + } + }); + ui.separator(); + if let Some(n) = none_label + && ui.button(n).clicked() + { + out = Some(None); + } + // "More Colors…": a hex field. + let id = ui.id().with("hex"); + let mut hex: String = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); + ui.horizontal(|ui| { + ui.label("More Colors… #"); + let r = ui.add(egui::TextEdit::singleline(&mut hex).desired_width(70.0).hint_text("RRGGBB")); + if r.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + && let Some(c) = Rgba::from_hex(&hex) + { + out = Some(Some(ColorRef::rgb(c))); + } + }); + ui.data_mut(|d| d.insert_temp(id, hex)); + if out.is_some() { + ui.close(); + } + out +} + +/// A row of gallery tiles drawn by `paint_tile`; returns the clicked index. +pub fn gallery( + ui: &mut Ui, + n: usize, + tile: Vec2, + selected: Option, + mut paint_tile: impl FnMut(&egui::Painter, Rect, usize), + mut tip: impl FnMut(usize) -> String, +) -> Option { + let t = Tokens::get(ui.ctx()); + let mut clicked = None; + for i in 0..n { + let (rect, resp) = ui.allocate_exact_size(tile, Sense::click()); + let painter = ui.painter_at(rect.expand(2.0)); + let sel = selected == Some(i); + if sel || resp.hovered() { + painter.rect_filled(rect.expand(1.0), CornerRadius::same(4), if sel { t.selected } else { t.hover }); + } + paint_tile(&painter, rect.shrink(3.0), i); + if sel { + painter.rect_stroke(rect.expand(1.0), CornerRadius::same(4), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + if resp.on_hover_text(tip(i)).clicked() { + clicked = Some(i); + } + } + clicked +} + +/// A labelled number field with up/down steppers. Returns the new value when committed. +pub fn spinner(ui: &mut Ui, id: &str, value: f64, step: f64, suffix: &str, width: f32) -> Option { + let t = Tokens::get(ui.ctx()); + let key = ui.id().with(id); + let mut text: String = ui.data_mut(|d| d.get_temp::(key)).unwrap_or_else(|| fmt_num(value, suffix)); + let mut out = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(width).font(theme::font(12.0))); + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(key, text.clone())); + } else { + ui.data_mut(|d| d.remove::(key)); + } + if r.lost_focus() { + let num: String = text.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); + if let Ok(v) = num.parse::() { + out = Some(v); + } + } + let (rect, _) = ui.allocate_exact_size(vec2(12.0, 22.0), Sense::hover()); + let up = ui.interact(Rect::from_min_max(rect.min, pos2(rect.max.x, rect.center().y)), key.with("up"), Sense::click()); + let down = ui.interact(Rect::from_min_max(pos2(rect.min.x, rect.center().y), rect.max), key.with("dn"), Sense::click()); + let p = ui.painter(); + let c = rect.center(); + p.line_segment([pos2(c.x - 3.0, c.y - 3.0), pos2(c.x, c.y - 6.0)], Stroke::new(1.0, t.text_dim)); + p.line_segment([pos2(c.x, c.y - 6.0), pos2(c.x + 3.0, c.y - 3.0)], Stroke::new(1.0, t.text_dim)); + p.line_segment([pos2(c.x - 3.0, c.y + 3.0), pos2(c.x, c.y + 6.0)], Stroke::new(1.0, t.text_dim)); + p.line_segment([pos2(c.x, c.y + 6.0), pos2(c.x + 3.0, c.y + 3.0)], Stroke::new(1.0, t.text_dim)); + if up.clicked() { + out = Some(value + step); + } + if down.clicked() { + out = Some(value - step); + } + }); + out +} + +pub fn fmt_num(v: f64, suffix: &str) -> String { + let s = format!("{v:.2}"); + let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + format!("{s}{suffix}") +} + +/// Section header inside panes. +pub fn section(ui: &mut Ui, title: &str, open: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + let p = ui.painter(); + let c = pos2(rect.min.x + 8.0, rect.center().y); + if open { + chevron(p, c, 3.5, t.text); + } else { + p.line_segment([pos2(c.x - 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y)], Stroke::new(1.2, t.text)); + p.line_segment([pos2(c.x + 2.0, c.y), pos2(c.x - 2.0, c.y + 4.0)], Stroke::new(1.2, t.text)); + } + p.text(pos2(rect.min.x + 18.0, rect.center().y), Align2::LEFT_CENTER, title, theme::bold(13.0), t.text); + if resp.clicked() { !open } else { open } +}