diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index f0d6d822..3299a639 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -7,6 +7,8 @@ fails if a file under `assets/`, `docs/images/` or `docs/brand/` is missing from
DeckCraft contains **no Microsoft, Adobe, Avid or Autodesk iconography, images, fonts, themes,
templates or other assets**. Its themes, colour schemes, layouts, preset-shape geometry, sample
deck and UI icons are original work of the DeckCraft contributors (drawn or generated in code).
+Icons placed from Insert › Icons are Lucide paths (ISC; Feather-derived icons MIT), not Microsoft
+artwork. The ribbon and menu icons stay original drawings.
## In this repository
@@ -26,11 +28,14 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
| `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots |
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
+| `crates/media/tests/fixtures/damaged/*` (`ebml-header.mkv`, `ebml-position.webm`, `demux-loop.mp4`, `demux-loop.m4a`) | DeckCraft contributors | the `clip-*` and `tone*` fixtures above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged media must not panic or hang |
+| `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort |
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
| `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
| `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
+| Lucide icon paths in `crates/engine/src/icon_data.rs` (Insert › Icons) | Lucide Contributors; Feather icons by Cole Bemis | https://github.com/lucide-icons/lucide/tree/440a1e1dfb1e712505308da46bead3c28b1f1398/icons (release 1.56.0, 2026-10-13) | ISC; MIT for the Feather-derived icons named in that commit's LICENSE | Stroke icons inserted as shapes |
## Bundled through dependencies
diff --git a/Cargo.lock b/Cargo.lock
index e91dbf00..e2bc7965 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -892,7 +892,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"arboard",
"cpal",
@@ -911,7 +911,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -920,7 +920,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -929,14 +929,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"proptest",
]
[[package]]
name = "deckcraft-cli"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -945,15 +945,16 @@ dependencies = [
[[package]]
name = "deckcraft-color"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
+ "deckcraft-anim",
"deckcraft-color",
"deckcraft-fonts",
"deckcraft-format",
@@ -966,15 +967,17 @@ dependencies = [
"deckcraft-text",
"i_overlay",
"image",
+ "js-sys",
"log",
"serde",
"serde_json",
"thiserror 2.0.21",
+ "zip",
]
[[package]]
name = "deckcraft-fonts"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -986,7 +989,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -997,7 +1000,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1005,7 +1008,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1014,7 +1017,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1023,7 +1026,7 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-bitstream",
"thiserror 2.0.21",
@@ -1031,11 +1034,11 @@ dependencies = [
[[package]]
name = "deckcraft-matroska"
-version = "0.6.1"
+version = "0.7.0"
[[package]]
name = "deckcraft-mcp"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1045,7 +1048,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1063,15 +1066,16 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
+ "image",
"kurbo",
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-model"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1083,15 +1087,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
-version = "0.6.1"
+version = "0.7.0"
[[package]]
name = "deckcraft-opus"
-version = "0.6.1"
+version = "0.7.0"
[[package]]
name = "deckcraft-pdf"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1099,6 +1103,7 @@ dependencies = [
"deckcraft-render",
"deckcraft-text",
"hayro-syntax",
+ "krilla",
"kurbo",
"log",
"serde",
@@ -1108,7 +1113,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1116,7 +1121,7 @@ dependencies = [
"deckcraft-model",
"image",
"log",
- "quick-xml 0.38.4",
+ "quick-xml",
"serde_json",
"thiserror 2.0.21",
"zip",
@@ -1124,7 +1129,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1143,7 +1148,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1157,7 +1162,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1179,7 +1184,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1188,7 +1193,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -2432,6 +2437,7 @@ dependencies = [
"indexmap",
"pdf-writer",
"png",
+ "rayon",
"rustc-hash 2.1.3",
"rustybuzz",
"siphasher",
@@ -3455,15 +3461,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
-[[package]]
-name = "quick-xml"
-version = "0.38.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
-dependencies = [
- "memchr",
-]
-
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -4937,7 +4934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
- "quick-xml 0.41.0",
+ "quick-xml",
"quote",
]
@@ -5723,7 +5720,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.6.1"
+version = "0.7.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 03d475c8..c9cb8182 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
-version = "0.6.1"
+version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -59,7 +59,7 @@ vello_common = { version = "0.2", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
i_overlay = "9"
-quick-xml = "0.38"
+quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# ui
diff --git a/NOTICE b/NOTICE
index 1ebe2acd..79a0babb 100644
--- a/NOTICE
+++ b/NOTICE
@@ -19,6 +19,51 @@ list (author, source, license) is in ATTRIBUTION.md.
ATTRIBUTION.md (Inter, Carlito, Caladea, Liberation, Montserrat, Lato, Open Sans, Roboto,
Merriweather, Playfair Display, Poppins, Nunito Sans, BIZ UD, Shippori Mincho, Noto Sans
Arabic) under the SIL Open Font License 1.1; their licence texts ship with release packages.
+- Lucide icon paths embedded in crates/engine/src/icon_data.rs (Insert › Icons), release 1.56.0,
+ commit 440a1e1dfb1e712505308da46bead3c28b1f1398, https://github.com/lucide-icons/lucide.
+ ISC License, Copyright (c) 2026 Lucide Icons and Contributors. Icons Lucide derives from Feather
+ are MIT, Copyright (c) 2013-present Cole Bemis; which names those are is the list in Lucide's
+ LICENSE at that commit. Among the paths embedded here, the Feather-derived names are arrow-down,
+ arrow-left, arrow-right, arrow-up, at-sign, calendar, check, chevron-right, clipboard, clock,
+ compass, download, info, key, link, lock, minus, moon, move, music, plus, search, target and x.
+
+ ISC License
+
+ Copyright (c) 2026 Lucide Icons and Contributors
+
+ Permission to use, copy, modify, and/or distribute this software for any
+ purpose with or without fee is hereby granted, provided that the above
+ copyright notice and this permission notice appear in all copies.
+
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+ The MIT License (MIT) (for the Feather-derived icons)
+
+ Copyright (c) 2013-present Cole Bemis
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
Code dependencies
diff --git a/README.md b/README.md
index cffc5de7..82903b44 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@
-
+
@@ -75,7 +75,7 @@ Today it can:
- **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations,
by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback.
- **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts
- with selectable text), PNG/JPEG and outlines. Unsaved work is kept by AutoRecover.
+ with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover.
- **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an
MCP server for AI agents.
@@ -153,11 +153,12 @@ deckcraft-cli commands format. # list commands
deckcraft-cli run --sample --cmd 'slide.new={"layout":"titleOnly","title":"Hello"}' --save hello.deckcraft
deckcraft-cli mcp # MCP server over stdio (headless)
deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --control 7990)
+deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # confine the headless agent to folders
```
## Roadmap
-DeckCraft is in **alpha**. It covers **79% of PowerPoint's features by breadth** (92% of the core
+DeckCraft is in **alpha**. It covers **84% of PowerPoint's features by breadth** (92% of the core
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
@@ -193,6 +194,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
+DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or
+`dxil.dll` is needed ("Parent device is lost" at start, #153). `$env:WGPU_DX12_COMPILER = "dxc"`
+(or `"auto"`) selects wgpu's DXC handling again for troubleshooting.
+
### macOS
| Build | File | Notes |
diff --git a/ROADMAP.md b/ROADMAP.md
index d75a8a03..ce8c4a9f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -14,8 +14,8 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av
| | Value | Kind |
|---|---|---|
-| **Feature breadth** | **79%** weighted over 191 PowerPoint features (P0 92%, P1 75%, P2 36%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
-| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands |
+| **Feature breadth** | **84%** weighted over 191 PowerPoint features (P0 92%, P1 82%, P2 55%) | measured: `cargo xtask parity` over [docs/parity-checklist.md](docs/parity-checklist.md) |
+| Menu-bar coverage | 59% of 217 app-specific PowerPoint menu items matched by name (lower bound) | measured: script over the PowerPoint menu dump and the 226 commands at d556ff9 (the registry has grown since; not re-measured) |
| **Ready for real work** | **~55%** | estimated: weighted dimensions below |
| Mainstream practitioner | ~53% | estimated: weekly-use areas × discounts for interaction (×0.92), stability (×0.92) and `.pptx` exchange (×0.88), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
| Essentials user | ~62% | estimated: core features × discounts for launch (×0.90), clarity (×0.95) and opening received files (×0.90), [method](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user) |
@@ -45,7 +45,7 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b
| Dimension | Ready | Hours to full | Doc |
|---|---:|---|---|
-| Features (breadth 79% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
+| Features (breadth 84% measured, with depth) | ~62% | 140–240 | [docs/parity-checklist.md](docs/parity-checklist.md) |
| UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) |
| File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) |
| Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) |
@@ -61,26 +61,26 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b
| Area | Breadth (measured) | Ready (est.) | Hours | Doc |
|---|---:|---:|---|---|
-| Application shell | 71% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) |
-| Slides and sections | 90% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) |
-| Views | 90% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) |
-| Masters and themes | 85% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) |
-| Shapes | 86% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) |
-| Format (fill, line, effects) | 81% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) |
+| Application shell | 81% | ~55% | 8–14 | [ui-parity](docs/ui-parity.md) |
+| Slides and sections | 92% | ~85% | 3–5 | [checklist](docs/parity-checklist.md) |
+| Views | 98% | ~80% | 3–6 | [ui-parity](docs/ui-parity.md) |
+| Masters and themes | 92% | ~70% | 8–14 | [checklist](docs/parity-checklist.md) |
+| Shapes | 91% | ~75% | 8–14 | [ui-parity](docs/ui-parity.md) |
+| Format (fill, line, effects) | 83% | ~65% | 8–14 | [ui-parity](docs/ui-parity.md) |
| Arrange | 100% | ~90% | 1–2 | [ui-parity](docs/ui-parity.md) |
-| Text | 80% | ~62% | 18–30 | [gaps](docs/gaps.md#features) |
-| Tables | 89% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) |
+| Text | 83% | ~62% | 18–30 | [gaps](docs/gaps.md#features) |
+| Tables | 97% | ~75% | 3–6 | [checklist](docs/parity-checklist.md) |
| Charts | 65% | ~45% | 12–20 | [gaps](docs/gaps.md#charts-data-grid-elements-and-the-newer-chart-types) |
-| SmartArt | 33% | ~25% | 10–16 | [gaps](docs/gaps.md#smartart-is-pre-drawn-shapes-only) |
-| Pictures | 48% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) |
+| SmartArt | 50% | ~35% | 8–14 | [gaps](docs/gaps.md#smartart-editing-is-shallow) |
+| Pictures | 60% | ~45% | 12–20 | [file formats](docs/file-format-parity.md) |
| Media | 67% | ~60% | 8–14 | [file formats](docs/file-format-parity.md) |
-| Transitions | 92% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
-| Animations | 79% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
-| Slide show and presenter view | 66% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
-| Review | 60% | ~45% | 8–14 | [gaps](docs/gaps.md) |
-| Draw (ink) | 33% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
-| Undo and clipboard | 88% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
-| Files (commands; formats above) | 69% | ~50% | in file formats | [file formats](docs/file-format-parity.md) |
+| Transitions | 100% | ~80% | 4–8 | [animation-parity](docs/animation-parity.md) |
+| Animations | 85% | ~65% | 10–16 | [animation-parity](docs/animation-parity.md) |
+| Slide show and presenter view | 68% | ~60% | 10–16 | [slideshow-parity](docs/slideshow-parity.md) |
+| Review | 70% | ~45% | 8–14 | [gaps](docs/gaps.md) |
+| Draw (ink) | 50% | ~30% | 5–9 | [hardware-parity](docs/hardware-parity.md) |
+| Undo and clipboard | 100% | ~75% | 3–5 | [ui-parity](docs/ui-parity.md) |
+| Files (commands; formats above) | 75% | ~50% | in file formats | [file formats](docs/file-format-parity.md) |
## Languages
@@ -111,8 +111,11 @@ Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
image paste (#53), editor soak tests. 10–20 h.
-2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG.
- 36–64 h.
+2. **PPTX on real decks:** the owned corpus now round-trips comments, links and actions, custom
+ shows, rotated groups, inherited placeholders, text effects and body layout, theme effects,
+ fills, bevels, connectors, tables, charts, animations, media, sections, masters, 3-D models
+ and animation bounce end. Still open: a corpus of real decks, #70, #74, #77, EMF+ records,
+ MicroType Express font subsets and SVG. 36–64 h.
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
4. **Output:** vector PDF, printing. 10–16 h.
5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline,
@@ -130,9 +133,75 @@ app's JSON control channel (`deckcraft --control PORT`).
|---|---|
| 2026-10-13 | Interface font fallback continues to eligible installed faces when the first covering face is colour or exceeds the 48 MiB limit; document fallback order and the CJK/IME rendering assertions are retained (#101) |
| 2026-10-13 | PDFW2: adopt the shared writer with native unwind recovery and explicit font-embedding failures; preserve app-owned export behavior and keep reader dependencies out of production |
+| 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) |
+| 2026-10-13 | Slide show: Honeycomb's hexagons cover the whole slide again — the bottom quarter stayed black until the endpoint (#420) |
+| 2026-10-13 | Copy and Cut from the ribbon, the slide's right-click menu and the menus put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431) |
+| 2026-10-13 | Video seeks and loops keep newly decoded frames and end-of-stream notifications; stale generations are filtered when consumed (upstream #416). Deterministic queue coverage and repeated seeks across H.264, HEVC, VP9 and AV1 |
+| 2026-10-14 | Presenter View handles the same keyboard navigation as the audience window, consumes handled keys before focused buttons, and remains available at the end screen. Regression tests cover presenter-only input, single-step navigation and Escape |
+| 2026-10-13 | EMF and WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records are still skipped |
+| 2026-10-13 | Embedded fonts (`p:embeddedFontLst`) are read, written and used to draw. TrueType, OpenType and Embedded OpenType faces load; MicroType Express subsets are kept for the round trip and still draw in a stand-in |
+| 2026-10-13 | Codec parsers (#426): H.264 and HEVC quantiser offsets, Matroska block times, Opus granule timing and QuickTime PCM frame sizes are range-checked before the arithmetic, so a hostile number is a format error instead of an overflow |
+| 2026-10-13 | WordArt Transform: Shape Format ▸ Text Effects shows the 40 text warps and No Transform; `format.textWarp` applies one to the selected text. Adjust handles are still open |
+| 2026-10-13 | PPTX corpus round-trip for decks we author (open → save → reopen, package parts and text). A notes master's header and footer are kept instead of being replaced by an empty notes master |
+| 2026-10-13 | PPTX round trip of comments, hyperlinks and actions, custom shows, rotated groups, and placeholders that inherit from the layout. Paragraph default formatting (`defRPr`, including a link) and action sounds (the clip, stop-previous, and a custom show's "Show and return") are kept. Embedded fonts and comment threads stay open |
+| 2026-10-13 | PPTX round trip of text effects, autofit, columns, vertical and RTL text, tab stops, line spacing, theme effects, gradient and pattern fills, 3-D bevels and connectors. A run keeps its inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML is written back. Embedded fonts and #70, #74, #77 stay open |
+| 2026-10-14 | PPTX: an owned corpus round-trips SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a hidden slide, slide-number and date fields, ink (as freeform lines), 3-D models (`.glb`, including the fallback picture) and animation repeat, bounce end and triggers. Relationship-bearing SmartArt and OLE (#203), playback options (#74) and rewind (#77) stay open |
+| 2026-10-14 | Chart trendlines are drawn: linear, exponential, logarithmic, polynomial, power and moving average, with forecast and the equation and R² label. The value axis grows to include the fit |
+| 2026-10-14 | Chart trendlines round-trip (type, polynomial order, moving-average period, forecast, intercept, equation and R²). An owned corpus slice also checks combo charts with data labels, merged tables with diagonal borders, and text columns, autofit, vertical text and a WordArt warp |
+| 2026-10-14 | Insert › Icons: a gallery of Lucide stroke icons (release 1.56.0) inserted as custom shapes, no fill, round stroke scaled from the 24-unit design |
+| 2026-10-14 | Size and Position dialog: height, width, rotation, scale and lock aspect ratio in one undo step, from Shape Format › Size and the shape menu |
+| 2026-10-14 | Review: Previous and Next comment walk every comment in the deck; the Comments pane lists every slide and highlights the one you landed on |
+| 2026-10-13 | View › New Window opens a second window on the same presentation. The window has its own slide and shows edits made in the first window. Arrange All is not included |
+| 2026-10-14 | File › New starter decks: Briefing, Lesson, Pitch, Field Notes, Quarterly and Evening Talk, original text on our themes (`file.new {template}`, `file.templates`). The large template gallery is still open |
+| 2026-10-14 | Slide Sorter sections: Add Section, rename in place, collapse a section, and drag the bar to move that section and its slides |
+| 2026-10-14 | Text: an input method's composition in a table cell is underlined in place, with the caret and candidate window in that cell |
+| 2026-10-14 | Edit Photo Album (`insert.photoAlbumEdit`): reopen the album's slides and re-flow them in one undo step, with black and white, rotation, brightness, contrast and an album theme |
+| 2026-10-14 | Photo Album dialog (`insert.photoAlbum`): pictures and text boxes become slides in the open presentation, one undo step |
+| 2026-10-14 | Design › Colors › Customize Colors… opens Create New Theme Colors: the open master's scheme name and twelve theme colours, applied in one undo |
+| 2026-10-14 | Design › Fonts › Customize Fonts… opens Create New Theme Fonts: the open master's scheme name and its heading and body latin faces, applied in one undo |
+| 2026-10-14 | Alt Text: Mark as decorative opens checked when the selection already is, so OK no longer clears it |
+| 2026-10-14 | Insert Hyperlink: address, email, slide and ScreenTip open from the selection, so OK no longer replaces them with `https://` and an empty tip |
+| 2026-10-14 | Insert › Date and Time: the ribbon and Insert menu open a format list (datetime1–datetime13) with a language and Update automatically. OK inserts one field, or the shown text when Update is off. English has every format; en-GB, French, Japanese and Swedish list the numeric date and 24-hour time |
+| 2026-10-14 | View: the Zoom dialog takes a percent or Fit and applies it on OK. View ▸ Zoom opens it; the arrow still lists the presets |
+| 2026-10-13 | PPTX corpus round-trip for tables, charts, animations, media, sections and masters/layouts (open → save → reopen, package parts and text). A handout master's header, footer, date and slide number are kept |
+| 2026-10-13 | Playback stays with its document and its clip (#415): switching decks or closing the last one stops the previous sound, replacing a clip plays the new one, and the duration and decode caches keep the bytes they are keyed by |
+| 2026-10-13 | Slide show: a laser pointer (Cmd/Ctrl+L, the show's laser colour, no ink). Checklist row Partial. The presenter window does not move the dot |
+| 2026-10-14 | Slide Show: Show Media Controls writes the show setting and hides the playback bar and GIF button during the show |
+| 2026-10-14 | During a show, a typed slide number is shown and Enter goes to that slide |
+| 2026-10-14 | A custom show plays only its slides: Show in the Custom Shows dialog, Set Up Slide Show, or a click action |
+| 2026-10-14 | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement stays with #433 |
+| 2026-10-14 | See All Slides and zoom during a show: a thumbnail grid (G, or All), click to jump, and click-to-zoom at 2× (Z). Presenter view has Black, White, All slides and Zoom |
+| 2026-10-13 | The command palette skips commands that need a value, so searching for Export runs the export dialog (#79) |
+| 2026-10-14 | PNG and JPEG slide export record their resolution. The default 2 pixels per point is 144 dpi, so Preview and print show the slide's real size instead of twice that |
+| 2026-10-14 | Design ▸ Effects: named effect schemes (DeckCraft, Soft, Crisp, Lift, Glow, Plain) replace the theme's fills, lines and effects and leave colours and fonts. `design.effects` |
+| 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`) |
+| 2026-10-14 | Trim Media: start, end and fades open from the selected clip, so OK no longer resets them to zero |
+| 2026-10-14 | Format Background: a solid fill's transparency, and for a picture transparency, tile, offset, scale, alignment and mirror. Apply to All copies a picture background onto every slide. The same `design.background` command; dialogs stay 27 |
+| 2026-10-13 | Editor slide rasters sit on the physical pixel grid, so small text stays sharp on a Retina display instead of being blurred by a linear filter between pixels (#184) |
+| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them); a shape with no text still copies as DeckCraft objects so Ctrl+V pastes it (#182) |
+| 2026-10-13 | Animations ▸ Preview plays the current slide's animations on the editor canvas (Play All does the same); Escape or a click stops it. Checklist row Preview in editor re-scored done |
+| 2026-10-14 | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged |
+| 2026-10-14 | A trackpad swipe turns the page: sideways when the slide fits the window, and any flick during the slide show. One gesture turns one page. Pinch-zoom is unchanged |
+| 2026-10-14 | Format Shape: the Fill transparency slider shows the selected solid fill, and leaving it no longer clears that transparency |
+| 2026-10-13 | Web file picker: a picture, sound, video or outline finishes on the document and slide that asked for it, and is dropped if that presentation was closed or the picker dismissed (#414) |
+| 2026-10-14 | Paragraph dialog (Home › Paragraph launcher): alignment, first-line and hanging indents, spacing before and after, line spacing, and a Tabs dialog, each applied in one undo step |
+| 2026-10-14 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step |
+| 2026-10-13 | Grid Options: spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn; the on-screen grid uses that spacing |
+| 2026-10-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) |
+| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read |
+| 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it |
+| 2026-10-13 | A presentation whose object ids are used up refuses to grow instead of handing out duplicate ids (#407) |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
+| 2026-10-13 | Slide show for interactive decks: click-triggered animations play (one step per click, repeating, applied in click order), a slide the show returns to keeps its state and its triggered animations go on where they stopped, shapes an animation has hidden cannot be clicked, a shape without an action blocks the one beneath it, hand cursor over clickable shapes, "advance on click" off respected |
+| 2026-10-12 | SmartArt import: text laid out on its `dsp:txXfrm` text rectangle, not squeezed into the shape (list layouts no longer show text in narrow columns) |
+| 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) |
+| 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) |
+| 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items |
+| 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes |
+| 2026-10-13 | Start screen: a missing Recent file is marked and its click shows the error there; entries can be removed from the list (#172) |
+| 2026-10-12 | Tables: the caret and text selection show in the cell being edited; clicks and double-clicks place the caret in the clicked cell (and select the word); dragging inner column borders, the right edge and row borders resizes them in one undo step; Tab skips cells hidden under a merge |
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
@@ -141,6 +210,8 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes |
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
+| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
+| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
@@ -152,9 +223,63 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
+| 2026-10-13 | trivial | Progress log: Eyedropper in the colour menus, slide.colorAt (#367) |
+| 2026-10-13 | trivial | Progress log: video seek frame-loss race and regression coverage |
+| 2026-10-14 | minor | Presenter keyboard navigation and end-screen controls |
+| 2026-10-13 | trivial | Progress log: EMF/WMF rectangle clip regions |
+| 2026-10-13 | trivial | Progress log: embedded fonts |
+| 2026-10-13 | trivial | Progress log: codec parsers reject overflowing numeric fields (#426) |
+| 2026-10-13 | trivial | Progress log: owned PPTX corpus round-trip; notes-master header and footer |
+| 2026-10-13 | trivial | Progress log: PPTX corpus for comments, links, custom shows, groups and inherited placeholders |
+| 2026-10-13 | trivial | Progress log: PPTX corpus for text effects, body layout, theme effects, fills, bevels and connectors |
+| 2026-10-14 | trivial | Progress log: PPTX corpus for 3-D models, media bookmarks and animation bounce end |
+| 2026-10-14 | trivial | Progress log: chart trendlines are drawn |
+| 2026-10-14 | trivial | Progress log: chart trendlines |
+| 2026-10-14 | trivial | Progress log: Lucide icon gallery |
+| 2026-10-14 | trivial | Progress log: Size and Position dialog. Breadth stays 79%, ready stays ~55% |
+| 2026-10-14 | trivial | Progress log: previous and next comment |
+| 2026-10-13 | trivial | Progress log: View › New Window |
+| 2026-10-14 | trivial | Progress log: six original starter decks |
+| 2026-10-14 | trivial | Progress log: Slide Sorter sections |
+| 2026-10-14 | trivial | Progress log: IME underlines in table cells |
+| 2026-10-14 | trivial | Progress log: Photo Album dialog. Breadth stays 79%, ready ~55% |
+| 2026-10-14 | trivial | Progress log: Edit Photo Album. Breadth stays 79%, ready ~55% |
+| 2026-10-14 | trivial | Progress log: Create New Theme Colors |
+| 2026-10-14 | trivial | Progress log: Create New Theme Fonts |
+| 2026-10-14 | trivial | Progress log: Alt Text decorative checkbox |
+| 2026-10-14 | trivial | Progress log: Hyperlink dialog follows the selection |
+| 2026-10-14 | trivial | Progress log: Insert › Date and Time dialog (28 dialogs, 226 commands; breadth and readiness unchanged) |
+| 2026-10-14 | trivial | Progress log: Zoom dialog |
+| 2026-10-13 | trivial | Progress log: owned PPTX corpus for more deck kinds; handout-master text |
+| 2026-10-13 | trivial | Progress log: media playback follows the open document and clip (#415) |
+| 2026-10-13 | trivial | Progress log: laser pointer. Headline P2 36% → 39% to match the checklist (the row moves P2 unweighted 38% → 39%; breadth stays 79%) |
+| 2026-10-14 | trivial | Progress log: Show Media Controls |
+| 2026-10-14 | trivial | Progress log: go to slide shows the typed number. Breadth stays 79% |
+| 2026-10-14 | trivial | Progress log: custom show playback. Breadth stays 79% |
+| 2026-10-14 | trivial | Progress log: presenter timer pause/reset, clock and notes size. Breadth stays 79% |
+| 2026-10-14 | trivial | Progress log: See All Slides and zoom during a show. Breadth stays 79% |
+| 2026-10-13 | trivial | Progress log: command palette Export (#79) |
+| 2026-10-14 | trivial | Progress log: Design ▸ Effects |
+| 2026-10-14 | trivial | Progress log: Slide Master Preserve and Title/Footers placeholders. Percentages unchanged |
+| 2026-10-14 | trivial | Progress log: Trim Media dialog follows the selection |
+| 2026-10-14 | trivial | Progress log: Format Background picture and solid options |
+| 2026-10-13 | trivial | Progress log: editor rasters on the Retina pixel grid (#184) |
+| 2026-10-14 | trivial | Progress log: copy and paste rich text and pictures with other apps |
+| 2026-10-13 | trivial | Progress log: animation preview on the editor canvas; breadth 80%, Animations 85% |
+| 2026-10-14 | trivial | Progress log: Format Shape fill transparency follows the selection |
+| 2026-10-13 | trivial | Progress log: web file picker keeps its document and slide (#414) |
+| 2026-10-14 | trivial | Progress log: Paragraph dialog |
+| 2026-10-14 | trivial | Progress log: Font dialog |
+| 2026-10-13 | trivial | Progress log: Grid Options dialog |
+| 2026-10-14 | trivial | Progress log: pen pressure scales ink width |
+| 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) |
| 2026-10-13 | trivial | Progress log: wild text cases |
+| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
+| 2026-10-12 | minor | Progress log: table cell editing and border resizing |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
+| 2026-10-11 | minor | Progress log: animated GIF export (#171); P2 breadth 36% → 37%, overall breadth stays 79% |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
diff --git a/apps/deckcraft-cli/src/automation_args.rs b/apps/deckcraft-cli/src/automation_args.rs
new file mode 100644
index 00000000..8f0cabf8
--- /dev/null
+++ b/apps/deckcraft-cli/src/automation_args.rs
@@ -0,0 +1,90 @@
+//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
+//! flag must not silently remove a root and leave the server unconfined.
+
+use std::path::PathBuf;
+
+pub struct AutomationArgs {
+ pub read_root: Option,
+ pub write_root: Option,
+ read_given: bool,
+ write_given: bool,
+}
+
+impl AutomationArgs {
+ pub fn new(read_root: Option, write_root: Option) -> Self {
+ Self { read_root, write_root, read_given: false, write_given: false }
+ }
+
+ /// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
+ /// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
+ pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result {
+ let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
+ let (root, given) = match flag {
+ "--automation-read-root" => (&mut self.read_root, &mut self.read_given),
+ "--automation-write-root" => (&mut self.write_root, &mut self.write_given),
+ _ => return Ok(false),
+ };
+ if *given {
+ return Err(format!("{flag} given twice"));
+ }
+ let value = match inline {
+ Some(value) => value.to_owned(),
+ None => {
+ let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
+ if value.starts_with('-') {
+ return Err(format!("{flag} needs a folder; use {flag}= for a folder starting with '-'"));
+ }
+ value
+ }
+ };
+ if value.is_empty() {
+ return Err(format!("{flag} needs a non-empty folder"));
+ }
+ *root = Some(value.into());
+ *given = true;
+ Ok(true)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roots_are_taken_once_and_other_arguments_are_left() {
+ let mut roots = AutomationArgs::new(None, None);
+ let mut args = ["a folder".to_owned(), "deck.pptx".to_owned()].into_iter();
+ assert!(roots.consume("--automation-read-root", &mut args).unwrap());
+ assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
+ assert!(!roots.consume("--sample", &mut args).unwrap());
+ assert_eq!(roots.read_root, Some("a folder".into()));
+ assert_eq!(roots.write_root, Some("out".into()));
+ assert_eq!(args.next().as_deref(), Some("deck.pptx"));
+ assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
+ assert_eq!(roots.read_root, Some("a folder".into()));
+ }
+
+ #[test]
+ fn invalid_operands_leave_roots_unchanged() {
+ for flag in ["--automation-read-root", "--automation-write-root"] {
+ for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
+ let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
+ let mut args = operand.map(str::to_owned).into_iter();
+ assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
+ assert_eq!(roots.read_root, Some("read".into()));
+ assert_eq!(roots.write_root, Some("write".into()));
+ }
+ let mut roots = AutomationArgs::new(None, None);
+ assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
+ }
+ }
+
+ #[test]
+ fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
+ let mut roots = AutomationArgs::new(None, None);
+ assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
+ assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
+ assert_eq!(roots.read_root, Some("-folder".into()));
+ assert_eq!(roots.write_root, Some("./-folder".into()));
+ }
+}
diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs
new file mode 100644
index 00000000..535ce848
--- /dev/null
+++ b/apps/deckcraft-cli/src/help.rs
@@ -0,0 +1,185 @@
+pub struct Subcommand {
+ pub name: &'static str,
+ pub usage: &'static str,
+ pub summary: &'static str,
+ pub details: &'static str,
+}
+
+pub const SUBCOMMANDS: &[Subcommand] = &[
+ Subcommand {
+ name: "info",
+ usage: "info FILE",
+ summary: "print a presentation's slides, layouts and theme as JSON",
+ details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes,
+notes, transition), its layouts, theme fonts, sections and slide size.
+
+The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command:
+
+ deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'",
+ },
+ Subcommand {
+ name: "render",
+ usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT",
+ summary: "draw slides as PNG images",
+ details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
+OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
+slide. Prints each image's path and size.
+
+ --slide N the slide at index N; indices start at 0, and 0 is the default
+ --scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
+ --edit draw what the editor shows: placeholder prompts and their dashed outlines
+
+ deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
+ },
+ Subcommand {
+ name: "render-transition",
+ usage: "render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png",
+ summary: "draw frames of the transition into a slide as PNG images",
+ details: "Draws the transition into slide N of FILE at each time T, from 0 (the slide before) to 1 (slide N),
+as PNG images. With one T, OUT.png is the image; with several, each frame is written next to it as
+OUT-t0.250.png and so on. Prints each image's path, size, transition and time.
+
+ --slide N the slide the transition leads into; indices start at 0
+ --t T,T... the times to draw, comma-separated
+ --width PX the image width in pixels, twice the slide width in points by default
+
+ deckcraft-cli render-transition deck.deckcraft --slide 1 --t 0.25,0.5,0.75 fade.png",
+ },
+ Subcommand {
+ name: "convert",
+ usage: "convert IN OUT",
+ summary: "save a presentation in the format OUT's extension names",
+ details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names:
+.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide, .gif for an
+animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
+
+ deckcraft-cli convert deck.deckcraft deck.pptx",
+ },
+ Subcommand {
+ name: "run",
+ usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
+ summary: "run commands on a presentation, then save or export it",
+ details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
+else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
+everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the
+current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per
+slide). --export-params passes file.export options as one JSON object, such as
+'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF
+(`deckcraft-cli describe file.export` lists them).
+
+Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
+JSON object. Quote it for the shell. A command that needs no parameters is just its id.
+
+ deckcraft-cli run \\
+ --cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
+ --cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
+ --cmd 'format.size={\"size\":28}' \\
+ --save q3.deckcraft
+
+`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its
+parameters.
+
+What the ids don't say:
+- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is
+ [x, y, width, height].
+- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current;
+ slide.go {\"index\": N} makes slide N current.
+- Shape ids are looked up on the current slide only.
+- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts
+ on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands
+ take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on.
+
+If a command fails, run stops there and saves nothing. --print shows each command's answer, such as
+the id of an inserted shape.",
+ },
+ Subcommand {
+ name: "commands",
+ usage: "commands [FILTER]",
+ summary: "list the commands run can use, one per line",
+ details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in
+the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case.
+
+ deckcraft-cli commands format.
+
+In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what
+the command answers. `deckcraft-cli describe COMMAND` spells one command out.",
+ },
+ Subcommand {
+ name: "describe",
+ usage: "describe COMMAND",
+ summary: "explain one command and its parameters",
+ details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run
+calls it.
+
+ deckcraft-cli describe shape.insert",
+ },
+ Subcommand {
+ name: "app",
+ usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]",
+ summary: "run a command in the DeckCraft app started with --control",
+ details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default)
+and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD
+[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object.
+
+ deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}'
+ deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'",
+ },
+ Subcommand {
+ name: "mcp",
+ usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]",
+ summary: "serve the commands to an agent over MCP on stdio",
+ details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
+window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
+--sample opens the sample presentation.
+
+--automation-read-root DIR and --automation-write-root DIR confine every file the agent's commands read
+(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed; a root left
+out grants none of its access. They run the in-process server, so they conflict with --connect (a running
+app is not confined by them).",
+ },
+ Subcommand {
+ name: "bench",
+ usage: "bench (FILE | --sample) [--runs N] [--json]",
+ summary: "time opening, drawing, saving and exporting a presentation",
+ details: "Times how long FILE, or the sample presentation with --sample, takes to open, to draw each slide, to
+save as .deckcraft and .pptx, to reopen as .pptx and to export as .pdf, and the peak memory. Prints the
+median and slowest of N runs (5 by default, 1 to 1000) as a table, or as JSON with --json.
+
+ deckcraft-cli bench deck.pptx --runs 10",
+ },
+ Subcommand {
+ name: "links",
+ usage: "links",
+ summary: "print the DeckCraft community and project links",
+ details: "Prints the DeckCraft community and project links.",
+ },
+ Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." },
+];
+
+pub fn page(name: &str) -> Option {
+ let name = match name {
+ "help" | "--help" | "-h" => return Some(overview()),
+ "--version" | "-V" => "version",
+ name => name,
+ };
+ SUBCOMMANDS.iter().find(|s| s.name == name).map(|s| format!("usage: deckcraft-cli {}\n\n{}", s.usage, s.details))
+}
+
+pub fn overview() -> String {
+ let width = SUBCOMMANDS.iter().map(|s| s.usage.split(' ').next().unwrap_or_default().len()).max().unwrap_or_default();
+ let list: Vec =
+ SUBCOMMANDS.iter().map(|s| format!(" {: ExitCode {
let args: Vec = std::env::args().skip(1).collect();
let rest = args.get(1..).unwrap_or(&[]);
+ let asks_help = rest.iter().any(|a| a == "--help" || a == "-h");
+ if let Some(page) = args.first().filter(|_| asks_help).and_then(|n| help::page(n)) {
+ outln!("{page}");
+ return ExitCode::SUCCESS;
+ }
match args.first().map(String::as_str) {
+ Some("help" | "--help" | "-h") => match rest.first() {
+ None => {
+ outln!("{}", help::overview());
+ ExitCode::SUCCESS
+ }
+ Some(name) => match help::page(name) {
+ Some(page) => {
+ outln!("{page}");
+ ExitCode::SUCCESS
+ }
+ None => unknown(name),
+ },
+ },
Some("--version" | "-V" | "version") => {
outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Some("info") => report(info(rest)),
+ Some("bench") => report(bench(rest)),
Some("render") => report(render(rest)),
Some("render-transition") => report(render_transition(rest)),
Some("convert") => report(convert(rest)),
Some("run") => report(run(rest)),
Some("commands") => {
- let s = Session::new();
let f = rest.first().map(|x| x.to_lowercase());
- let list: Vec = s
- .commands()
- .into_iter()
+ for c in deckcraft_engine::command_specs()
+ .iter()
.filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str())))
- .map(|c| serde_json::to_value(c).unwrap_or_default())
- .collect();
- outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
+ {
+ match c.params.strip_prefix("{}") {
+ Some(answer) => outln!("{} — {}{answer}", c.id, c.label),
+ None => outln!("{} — {}: {}", c.id, c.label, c.params),
+ }
+ }
ExitCode::SUCCESS
}
Some("describe") => report(describe(rest.first().map(String::as_str))),
@@ -77,14 +77,31 @@ fn main() -> ExitCode {
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
ExitCode::SUCCESS
}
- _ => {
- eprintln!("{USAGE}");
- eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
- ExitCode::FAILURE
- }
+ Some(other) => unknown(other),
+ None => usage(),
}
}
+fn is_help(arg: &str) -> bool {
+ matches!(arg, "--help" | "-h")
+}
+
+/// The help page of subcommand `name`, else the overview.
+fn help_page(name: &str) -> String {
+ help::page(name).unwrap_or_else(help::overview)
+}
+
+fn unknown(sub: &str) -> ExitCode {
+ eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n");
+ usage()
+}
+
+fn usage() -> ExitCode {
+ eprintln!("{}", help::overview());
+ eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
+ ExitCode::FAILURE
+}
+
fn report(r: Result<(), String>) -> ExitCode {
match r {
Ok(()) => ExitCode::SUCCESS,
@@ -105,7 +122,113 @@ fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String
}
}
+/// Median and slowest of `runs` timings of `f`, in milliseconds.
+fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f64, f64), String> {
+ let mut ms = Vec::with_capacity(runs);
+ for _ in 0..runs.max(1) {
+ let t = std::time::Instant::now();
+ f()?;
+ ms.push(t.elapsed().as_secs_f64() * 1000.0);
+ }
+ ms.sort_by(f64::total_cmp);
+ let median = ms.get(ms.len() / 2).copied().unwrap_or(0.0);
+ Ok((median, ms.last().copied().unwrap_or(0.0)))
+}
+
+/// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere.
+fn peak_rss_mib() -> Option {
+ let s = std::fs::read_to_string("/proc/self/status").ok()?;
+ let kb: f64 = s.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
+ Some(kb / 1024.0)
+}
+
+/// The performance baseline (docs/gaps.md "No baseline"): how long a deck takes to open, to
+/// render each slide, to save and to export, and the peak memory, as JSON or a table.
+fn bench(args: &[String]) -> Result<(), String> {
+ use deckcraft_engine::cmd::file::{open_presentation, save_bytes};
+ let mut input = None;
+ let mut sample = false;
+ let mut runs = 5usize;
+ let mut as_json = false;
+ let mut it = args.iter();
+ while let Some(a) = it.next() {
+ match a.as_str() {
+ "--sample" => sample = true,
+ "--json" => as_json = true,
+ "--runs" => {
+ runs = it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=1000).contains(n)).ok_or("--runs needs a number from 1 to 1000")?
+ }
+ f if !f.starts_with("--") && input.is_none() => input = Some(f.to_string()),
+ o => return Err(format!("bench: unknown argument `{o}`")),
+ }
+ }
+ if input.is_none() && !sample {
+ return Err("bench needs a FILE or --sample".into());
+ }
+ let mut s = Session::new();
+ let (name, bytes) = match &input {
+ Some(p) => (p.clone(), std::fs::read(p).map_err(|e| format!("{p}: {e}"))?),
+ None => {
+ open(&mut s, None, true)?;
+ let doc = &s.doc().map_err(|e| e.to_string())?.doc;
+ ("sample.deckcraft".to_string(), save_bytes(doc, "deckcraft").map_err(|e| e.to_string())?)
+ }
+ };
+ let e = |x: deckcraft_engine::EngineError| x.to_string();
+ let doc = open_presentation(&name, &bytes).map_err(e)?;
+ let slides = doc.slides.len();
+ let open = time_ms(runs, || open_presentation(&name, &bytes).map(|_| ()).map_err(e))?;
+ let opts = deckcraft_engine::render::RenderOpts { scale: 1.0, ..Default::default() };
+ let mut per_slide = vec![];
+ for i in 0..slides {
+ per_slide.push(time_ms(runs, || {
+ deckcraft_engine::render::render_slide(&doc, i, &opts);
+ Ok(())
+ })?);
+ }
+ let pptx = save_bytes(&doc, "pptx").map_err(e)?;
+ let mut steps = vec![
+ ("open", open),
+ ("save .deckcraft", time_ms(runs, || save_bytes(&doc, "deckcraft").map(|_| ()).map_err(e))?),
+ ("save .pptx", time_ms(runs, || save_bytes(&doc, "pptx").map(|_| ()).map_err(e))?),
+ ("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?),
+ ("export .pdf", time_ms(runs.min(3), || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
+ ];
+ let render_total: f64 = per_slide.iter().map(|m| m.0).sum();
+ let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max);
+ steps.insert(1, ("render all slides (1×)", (render_total, render_max)));
+ let peak = peak_rss_mib();
+ if as_json {
+ let v = json!({
+ "file": name,
+ "bytes": bytes.len(),
+ "slides": slides,
+ "runs": runs,
+ "steps": steps.iter().map(|(k, (m, x))| json!({"step": k, "medianMs": m, "maxMs": x})).collect::>(),
+ "renderMsPerSlide": per_slide.iter().map(|m| m.0).collect::>(),
+ "peakRssMiB": peak,
+ });
+ outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
+ } else {
+ outln!("{name}: {slides} slides, {} KiB, median of {runs} runs", bytes.len() / 1024);
+ for (k, (m, x)) in &steps {
+ outln!(" {k:<24} {m:>9.1} ms (slowest {x:.1} ms)");
+ }
+ if let Some((i, m)) = per_slide.iter().enumerate().max_by(|a, b| a.1.0.total_cmp(&b.1.0)) {
+ outln!(" slowest slide {:>9.1} ms (slide {})", m.0, i + 1);
+ }
+ if let Some(p) = peak {
+ outln!(" peak memory {p:>9.1} MiB");
+ }
+ }
+ Ok(())
+}
+
fn info(args: &[String]) -> Result<(), String> {
+ if args.first().is_some_and(|a| is_help(a)) {
+ outln!("{}", help_page("info"));
+ return Ok(());
+ }
let path = args.first().ok_or("info needs a FILE")?;
let mut s = Session::new();
open(&mut s, Some(path), false)?;
@@ -130,6 +253,11 @@ fn render(args: &[String]) -> Result<(), String> {
"--all" => all = true,
"--edit" => edit = true,
"--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
+ "--help" | "-h" => {
+ outln!("{}", help_page("render"));
+ return Ok(());
+ }
+ other if other.starts_with('-') => return Err(format!("unknown option `{other}`")),
other
if input.is_none()
&& !sample
@@ -148,14 +276,11 @@ fn render(args: &[String]) -> Result<(), String> {
open(&mut s, input.as_deref(), sample)?;
let st = s.doc().map_err(|e| e.to_string())?;
let n = st.doc.slides.len();
- let list: Vec = if all { (0..n).collect() } else { vec![slide.unwrap_or(0)] };
+ let list = slide_indices(n, all, slide)?;
if all {
std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?;
}
for i in list {
- if i >= n {
- return Err(format!("no slide {i} (the deck has {n})"));
- }
let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit);
let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() };
std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?;
@@ -164,6 +289,32 @@ fn render(args: &[String]) -> Result<(), String> {
Ok(())
}
+/// Slides `--slide` asks for. `N` is a 0-based index, the same one `slide.go` uses.
+/// `--all` still names files from 1 (`slide-01.png`), which is PowerPoint's slide number.
+fn slide_indices(n: usize, all: bool, slide: Option) -> Result, String> {
+ if all {
+ return Ok((0..n).collect());
+ }
+ let i = slide.unwrap_or(0);
+ if i >= n {
+ return Err(missing_slide(i, n));
+ }
+ Ok(vec![i])
+}
+
+fn missing_slide(i: usize, n: usize) -> String {
+ match n {
+ 0 => format!("no slide index {i} (this deck has no slides)"),
+ 1 => format!("no slide index {i} (this deck has 1 slide, index 0, which --all names slide-01.png)"),
+ n => {
+ let last = n - 1;
+ format!(
+ "no slide index {i} (this deck has {n} slides, numbered 0 through {last}; --all names them slide-01.png through slide-{n:02}.png)"
+ )
+ }
+ }
+}
+
/// Frames of slide N's transition (0-based N; from slide N-1, or black for the first slide) at
/// the given progress values. With several values, OUT gets `-t` before its extension.
fn render_transition(args: &[String]) -> Result<(), String> {
@@ -200,6 +351,7 @@ fn render_transition(args: &[String]) -> Result<(), String> {
if ts.is_empty() {
return Err("render-transition needs --t".into());
}
+ let paths = frame_paths(&out, &ts)?;
let mut s = Session::new();
open(&mut s, Some(&input), false)?;
let st = s.doc().map_err(|e| e.to_string())?;
@@ -217,34 +369,51 @@ fn render_transition(args: &[String]) -> Result<(), String> {
(prev.map(|p| render::render_slide(doc, p, &opts)), render::render_slide(doc, i, &opts), parts)
});
let aspect = sw / doc.slide_size.height.max(1.0);
- for t in &ts {
+ for (t, path) in ts.iter().zip(&paths) {
let img = match &cached {
Some((old, new, parts)) => {
render::composite_transition(old.as_ref(), new, parts.as_ref(), &render::transition_frame(&tr.kind, &tr.option, *t, aspect))
}
None => render::render_transition(doc, i, *t, &opts),
};
- let path = if ts.len() == 1 {
- out.clone()
- } else {
- let p = std::path::Path::new(&out);
- let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame");
- let name = format!("{stem}-t{t:.3}.png");
- p.with_file_name(name).to_string_lossy().into_owned()
- };
- std::fs::write(&path, img.to_png()).map_err(|e| format!("{path}: {e}"))?;
+ std::fs::write(path, img.to_png_at(render::dpi_for_scale(opts.scale))).map_err(|e| format!("{path}: {e}"))?;
outln!("{path} ({}×{}) {} {} t={t}", img.width, img.height, tr.kind, tr.option);
}
Ok(())
}
+/// Output path of each frame. A single frame goes to OUT; several get `-t` (three decimals)
+/// before the extension. Values that round to the same name would overwrite each other, so that is an error.
+fn frame_paths(out: &str, ts: &[f64]) -> Result, String> {
+ if ts.len() == 1 {
+ return Ok(vec![out.to_string()]);
+ }
+ let p = std::path::Path::new(out);
+ let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame");
+ let mut paths: Vec = Vec::with_capacity(ts.len());
+ for t in ts {
+ let path = p.with_file_name(format!("{stem}-t{t:.3}.png")).to_string_lossy().into_owned();
+ if paths.contains(&path) {
+ return Err(format!("--t values round to the same three decimals: more than one frame would be written to {path}"));
+ }
+ paths.push(path);
+ }
+ Ok(paths)
+}
+
fn convert(args: &[String]) -> Result<(), String> {
+ if args.first().is_some_and(|a| is_help(a)) {
+ outln!("{}", help_page("convert"));
+ return Ok(());
+ }
let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) };
+ let fmt = output_format(out)?;
let mut s = Session::new();
open(&mut s, Some(input), false)?;
- let fmt = deckcraft_engine::cmd::file::format_for_path(out);
let r = if matches!(fmt, "png" | "jpeg") {
s.execute("file.export", &json!({"path": out, "slide": 0}))
+ } else if fmt == "gif" {
+ s.execute("file.export", &json!({"path": out}))
} else {
s.execute("file.saveAs", &json!({"path": out}))
};
@@ -253,25 +422,50 @@ fn convert(args: &[String]) -> Result<(), String> {
Ok(())
}
+/// The format `convert`, `run --save` and `run --export` write to `path`, or an error naming the
+/// supported extensions when `path`'s extension isn't one (rather than native bytes under that name).
+fn output_format(path: &str) -> Result<&'static str, String> {
+ use deckcraft_engine::cmd::file::{WRITABLE_EXTENSIONS, writable_format_for_path};
+ writable_format_for_path(path).ok_or_else(|| {
+ let ext = std::path::Path::new(path).extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_else(|| "(none)".into());
+ format!("{path}: unsupported output format {ext} (supported: .{})", WRITABLE_EXTENSIONS.join(", ."))
+ })
+}
+
fn run(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut sample = false;
let mut cmds = vec![];
let mut save = None;
let mut export = None;
+ let mut export_params = json!({});
let mut print = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
"--sample" => sample = true,
- "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?),
+ "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
+ "--export-params" => {
+ let j = it.next().ok_or("--export-params needs a JSON object")?;
+ export_params = serde_json::from_str::(j).map_err(|e| format!("--export-params: bad JSON: {e}"))?;
+ if !export_params.is_object() {
+ return Err("--export-params must be a JSON object".into());
+ }
+ }
"--print" => print = true,
+ "--help" | "-h" => {
+ outln!("{}", help_page("run"));
+ return Ok(());
+ }
other => return Err(format!("unknown option `{other}`")),
}
}
+ for p in save.iter().chain(&export) {
+ output_format(p)?;
+ }
let mut s = Session::new();
open(&mut s, input.as_deref(), sample)?;
for c in cmds {
@@ -288,18 +482,76 @@ fn run(args: &[String]) -> Result<(), String> {
s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if let Some(p) = export {
- s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?;
+ export_params["path"] = json!(p);
+ s.execute("file.export", &export_params).map_err(|e| e.to_string())?;
+ } else if export_params != json!({}) {
+ return Err("--export-params needs --export OUT".into());
}
Ok(())
}
fn describe(id: Option<&str>) -> Result<(), String> {
+ if id.is_some_and(is_help) {
+ outln!("{}", help_page("describe"));
+ return Ok(());
+ }
let id = id.ok_or("describe needs a command id")?;
- let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?;
- outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params);
+ let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?;
+ let mut head = format!("{}: {}", c.id, c.label);
+ if !c.menu.is_empty() {
+ head.push_str(&format!(", in {}", c.menu.join(" ▸ ")));
+ }
+ if let Some(k) = c.shortcut {
+ head.push_str(&format!(", shortcut {k}"));
+ }
+ outln!("{head}.\n");
+ let (args, answer) = c.params.split_once('→').unwrap_or((c.params, ""));
+ let parsed = deckcraft_engine::cmd::parse_params(c.params);
+ match &parsed {
+ Err(_) => outln!("Parameters: {}", args.trim()),
+ Ok(params) if params.is_empty() => outln!("Takes no parameters."),
+ Ok(params) => print_params(params),
+ }
+ let params = parsed.unwrap_or_default();
+ if let Some(acts) = c.acts_on() {
+ outln!("{acts}");
+ }
+ let remark = remark(args);
+ if !remark.is_empty() {
+ outln!("Note: {remark}");
+ }
+ if !answer.trim().is_empty() {
+ outln!("Answers with {}.", answer.trim());
+ }
+ let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional));
+ let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) };
+ outln!("\nRun it with: deckcraft-cli run --cmd {call}");
Ok(())
}
+fn print_params(params: &[deckcraft_engine::cmd::Param]) {
+ let name = |p: &deckcraft_engine::cmd::Param| {
+ let or = if p.or { "or " } else { "" };
+ let optional = if p.optional { ", optional" } else { "" };
+ format!("{or}{}{optional}", p.name)
+ };
+ let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default();
+ let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0;
+ outln!("Parameters:");
+ for (k, p) in params.iter().enumerate() {
+ if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) {
+ outln!(" {}", if p.object == 0 { "either:" } else { "or:" });
+ }
+ let indent = if sets { " " } else { " " };
+ outln!("{}", format!("{indent}{: &str {
+ let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim();
+ after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after)
+}
+
fn app(args: &[String]) -> Result<(), String> {
use deckcraft_mcp::{Backend, Remote, control_addr};
let mut port = deckcraft_mcp::DEFAULT_PORT.to_string();
@@ -310,6 +562,10 @@ fn app(args: &[String]) -> Result<(), String> {
match a.as_str() {
"--port" => port = it.next().cloned().ok_or("--port needs a value")?,
"--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?),
+ "--help" | "-h" => {
+ outln!("{}", help_page("app"));
+ return Ok(());
+ }
other => rest.push(other.to_string()),
}
}
@@ -330,24 +586,47 @@ fn app(args: &[String]) -> Result<(), String> {
}
fn mcp(args: &[String]) -> Result<(), String> {
+ use deckcraft_engine::file_access::{self, AutomationRoots};
use deckcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
let mut connect: Option = None;
let mut sample = false;
- let mut it = args.iter();
+ let mut roots_args = automation_args::AutomationArgs::new(None, None);
+ let mut it = args.iter().cloned();
while let Some(a) = it.next() {
+ if roots_args.consume(&a, &mut it)? {
+ continue;
+ }
match a.as_str() {
- "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?),
+ "--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
"--sample" => sample = true,
+ "--help" | "-h" => {
+ outln!("{}", help_page("mcp"));
+ return Ok(());
+ }
other => return Err(format!("unknown mcp option `{other}`")),
}
}
+ let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
+ if roots.is_some() && connect.is_some() {
+ return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
+so drop --connect (or use --connect without the roots)"
+ .into());
+ }
let backend: Box = match connect {
Some(c) => Box::new(Remote::connect(&control_addr(&c)).map_err(|e| format!("can't reach the app at {c}: {e}"))?),
None => {
let mut h = Headless::new();
+ // `--sample` is the operator's own choice, made before the roots come into force.
if sample {
deckcraft_engine::sample::open_sample(&mut h.session).map_err(|e| e.to_string())?;
}
+ if let Some(roots) = roots {
+ // Every thread of this process, not only the server's: nothing reaches past the roots.
+ file_access::confine_process(roots.clone());
+ let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
+ eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
+ h = h.with_automation_roots(Some(roots));
+ }
Box::new(h)
}
};
@@ -355,3 +634,119 @@ fn mcp(args: &[String]) -> Result<(), String> {
let stdin = std::io::stdin();
server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string())
}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, clippy::expect_used)]
+
+ use super::*;
+
+ fn args(a: &[&str]) -> Vec {
+ a.iter().map(|s| s.to_string()).collect()
+ }
+
+ #[test]
+ fn render_help_prints_usage_and_writes_nothing() {
+ assert!(render(&args(&["--help"])).is_ok());
+ assert!(render(&args(&["-h"])).is_ok());
+ assert!(!std::path::Path::new("--help").exists());
+ }
+
+ #[test]
+ fn render_rejects_unknown_flag() {
+ let err = render(&args(&["--sample", "--scael", "2", "out.png"])).unwrap_err();
+ assert_eq!(err, "unknown option `--scael`");
+ assert!(!std::path::Path::new("--scael").exists());
+ }
+
+ #[test]
+ fn other_subcommands_accept_help() {
+ assert!(info(&args(&["--help"])).is_ok());
+ assert!(convert(&args(&["-h"])).is_ok());
+ assert!(run(&args(&["--help"])).is_ok());
+ assert!(describe(Some("--help")).is_ok());
+ assert!(mcp(&args(&["-h"])).is_ok());
+ }
+
+ #[test]
+ fn render_refuses_an_unknown_option_instead_of_writing_it_as_a_file() {
+ let out = std::env::temp_dir().join("deckcraft-cli-unknown-option.png");
+ let _ = std::fs::remove_file(&out);
+ for flag in ["--bogus", "-x"] {
+ let args: Vec = ["--sample", flag, &out.to_string_lossy()].iter().map(|a| a.to_string()).collect();
+ let e = render(&args).unwrap_err();
+ assert!(e.contains(&format!("unknown option `{flag}`")), "{flag}: {e}");
+ assert!(!out.exists(), "{flag}: rendered anyway");
+ }
+ }
+
+ /// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
+ #[test]
+ fn exports_an_animated_gif() {
+ let dir = std::env::temp_dir().join(format!("deckcraft-cli-gif-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let gif = dir.join("sample.gif").to_string_lossy().to_string();
+ let deck = dir.join("sample.deckcraft").to_string_lossy().to_string();
+ let params = r#"{"width": "small", "secondsPerSlide": 2, "loop": false, "slides": [0, 1]}"#;
+ run(&args(&["--sample", "--save", &deck, "--export", &gif, "--export-params", params])).unwrap();
+ let bytes = std::fs::read(&gif).unwrap();
+ assert!(bytes.starts_with(b"GIF89a"));
+ assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 640, "small = 640 px wide");
+ assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "play once: no loop block");
+
+ let gif2 = dir.join("converted.gif").to_string_lossy().to_string();
+ convert(&args(&[&deck, &gif2])).unwrap();
+ let bytes = std::fs::read(&gif2).unwrap();
+ assert!(bytes.starts_with(b"GIF89a") && bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "loops forever by default");
+
+ // Bad options are errors, not panics.
+ let bad = dir.join("bad.gif").to_string_lossy().to_string();
+ assert!(run(&args(&["--sample", "--export", &bad, "--export-params", r#"{"width": 1e9}"#])).is_err());
+ assert!(run(&args(&["--sample", "--export", &bad, "--export-params", "[1]"])).is_err());
+ assert!(run(&args(&["--sample", "--export-params", "{}"])).is_ok());
+ assert!(run(&args(&["--sample", "--export-params", r#"{"width": 64}"#])).is_err());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
+
+#[cfg(test)]
+mod slide_index_tests {
+ use super::{missing_slide, slide_indices};
+
+ #[test]
+ fn slide_numbers_start_at_zero_and_files_at_one() {
+ assert_eq!(slide_indices(8, false, None).ok().as_deref(), Some(&[0][..]));
+ assert_eq!(slide_indices(8, false, Some(0)).ok().as_deref(), Some(&[0][..]));
+ assert_eq!(slide_indices(8, false, Some(7)).ok().as_deref(), Some(&[7][..]));
+ assert_eq!(slide_indices(8, true, None).ok().as_deref(), Some(&[0, 1, 2, 3, 4, 5, 6, 7][..]));
+ let err = slide_indices(8, false, Some(8)).err().unwrap_or_default();
+ assert!(!err.is_empty(), "index 8 was accepted");
+ assert!(err.contains("numbered 0 through 7"), "{err}");
+ assert!(err.contains("slide-01.png"), "{err}");
+ assert!(err.contains("slide-08.png"), "{err}");
+ assert!(missing_slide(0, 0).contains("no slides"));
+ assert!(missing_slide(1, 1).contains("index 0"));
+ }
+}
+
+#[cfg(test)]
+mod frame_path_tests {
+ use super::frame_paths;
+
+ #[test]
+ fn single_frame_uses_out_as_is() {
+ assert_eq!(frame_paths("a/b.png", &[0.5]), Ok(vec!["a/b.png".to_string()]));
+ }
+
+ #[test]
+ fn frames_get_distinct_names() {
+ let paths = frame_paths("f.png", &[0.0, 0.25, 1.0]).unwrap_or_default();
+ assert_eq!(paths, ["f-t0.000.png", "f-t0.250.png", "f-t1.000.png"]);
+ }
+
+ #[test]
+ fn values_rounding_to_one_name_are_rejected() {
+ assert!(frame_paths("f.png", &[0.1001, 0.1002]).is_err());
+ assert!(frame_paths("f.png", &[0.5, 0.5]).is_err());
+ }
+}
diff --git a/apps/deckcraft-cli/tests/cli.rs b/apps/deckcraft-cli/tests/cli.rs
new file mode 100644
index 00000000..d04e55c7
--- /dev/null
+++ b/apps/deckcraft-cli/tests/cli.rs
@@ -0,0 +1,139 @@
+use std::process::{Command, Output};
+
+fn cli(args: &[&str]) -> Output {
+ Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
+}
+
+fn stdout(o: &Output) -> String {
+ assert!(o.status.success(), "exit {:?}\nstderr: {}", o.status, String::from_utf8_lossy(&o.stderr));
+ String::from_utf8_lossy(&o.stdout).into_owned()
+}
+
+fn stderr(o: &Output) -> String {
+ assert!(!o.status.success(), "exit {:?}\nstdout: {}", o.status, String::from_utf8_lossy(&o.stdout));
+ String::from_utf8_lossy(&o.stderr).into_owned()
+}
+
+const SUBCOMMANDS: [&str; 11] = ["info", "render", "render-transition", "convert", "run", "commands", "describe", "app", "mcp", "bench", "links"];
+
+fn words(s: &str) -> String {
+ s.split_whitespace().collect::>().join(" ")
+}
+
+#[test]
+fn an_unknown_subcommand_is_named_before_the_overview() {
+ for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
+ let err = stderr(&cli(&args));
+ assert!(err.contains("unknown subcommand `") && err.contains("\n\ndeckcraft-cli works on"), "{args:?}: {err}");
+ }
+}
+
+#[test]
+fn no_subcommand_shows_the_overview_and_fails() {
+ assert!(stderr(&cli(&[])).starts_with("deckcraft-cli works on"));
+}
+
+#[test]
+fn help_lists_every_subcommand_with_a_run_example() {
+ for flag in ["help", "--help", "-h"] {
+ let out = stdout(&cli(&[flag]));
+ for sub in SUBCOMMANDS {
+ assert!(out.contains(&format!("\n {sub} ")), "{flag} lacks {sub}: {out}");
+ }
+ assert!(out.contains("deckcraft-cli run --cmd 'slide.new={\"title\":\"Q3 results\"}' --save q3.deckcraft"), "{out}");
+ }
+}
+
+#[test]
+fn every_subcommand_answers_help_and_does_nothing_else() {
+ let dir = std::env::temp_dir().join(format!("dc-cli-help-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ for sub in SUBCOMMANDS {
+ for args in [vec![sub, "--help"], vec![sub, "-h", "out.png"], vec!["help", sub]] {
+ let o = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(&dir).args(&args).output().unwrap();
+ assert!(stdout(&o).starts_with(&format!("usage: deckcraft-cli {sub}")), "{args:?}");
+ }
+ }
+ assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "help wrote nothing");
+ std::fs::remove_dir_all(&dir).unwrap();
+ for args in [["help", "version"], ["--version", "--help"]] {
+ assert!(stdout(&cli(&args)).starts_with("usage: deckcraft-cli --version"), "{args:?}");
+ }
+ for args in [["help", "help"], ["help", "--help"]] {
+ assert!(stdout(&cli(&args)).starts_with("deckcraft-cli works on"), "{args:?}");
+ }
+}
+
+#[test]
+fn run_help_says_what_the_ids_do_not() {
+ let out = words(&stdout(&cli(&["run", "--help"])));
+ for fact in [
+ "COMMAND is a command id, then `=`, then the command's parameters as one JSON object.",
+ "points on a 960×540 slide",
+ "Slide indices start at 0.",
+ "Shape ids are looked up on the current slide only.",
+ "A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
+ "format.* acts on the selected text or shapes",
+ ] {
+ assert!(out.contains(fact), "{fact}: {out}");
+ }
+}
+
+#[test]
+fn describe_spells_out_a_command() {
+ assert_eq!(
+ stdout(&cli(&["describe", "shape.insert"])),
+ "shape.insert: Shapes, in Insert ▸ Illustrations.
+
+Parameters:
+ preset rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets)
+ rect [x,y,w,h] pt
+ text, optional string
+
+Run it with: deckcraft-cli run --cmd 'shape.insert={…}'
+"
+ );
+ assert_eq!(
+ stdout(&cli(&["describe", "format.size"])),
+ "format.size: Font Size, in Home ▸ Font.
+
+Parameters:
+ size pt
+Acts on the selected text or shapes.
+
+Run it with: deckcraft-cli run --cmd 'format.size={…}'
+"
+ );
+ assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n"));
+ assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n"));
+ assert_eq!(
+ stdout(&cli(&["describe", "shape.setDefault"])),
+ "shape.setDefault: Set as Default Shape.\n\nTakes no parameters.\nActs on the selected shapes.\n\nRun it with: deckcraft-cli run --cmd shape.setDefault\n"
+ );
+}
+
+#[test]
+fn describe_shows_alternative_sets_of_parameters() {
+ assert!(stdout(&cli(&["describe", "slide.go"])).contains("Parameters:\n either:\n index\n or:\n id\n"));
+ let remove = stdout(&cli(&["describe", "animation.remove"]));
+ assert!(remove.contains(" or:\n ids, optional (all of the shapes' animations)\n"), "{remove}");
+ assert!(remove.ends_with("Run it with: deckcraft-cli run --cmd animation.remove\n") && !remove.contains("Note:"), "{remove}");
+ assert!(stdout(&cli(&["describe", "shape.move"])).contains(
+ " either:
+ dx, optional pt (relative)
+ dy, optional pt (relative)
+ ids, optional
+ or:
+ x, optional pt (absolute)
+ y, optional pt (absolute)
+ ids, optional
+"
+ ));
+}
+
+#[test]
+fn commands_lists_one_line_per_command() {
+ assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt}\n");
+ assert_eq!(stdout(&cli(&["commands", "slide.next"])), "slide.next — Next Slide\n");
+ assert_eq!(stdout(&cli(&["commands", "shape.presets"])), "shape.presets — Shape Gallery → [{name, label, category}]\n");
+}
diff --git a/apps/deckcraft-cli/tests/convert.rs b/apps/deckcraft-cli/tests/convert.rs
new file mode 100644
index 00000000..4f162994
--- /dev/null
+++ b/apps/deckcraft-cli/tests/convert.rs
@@ -0,0 +1,57 @@
+//! `deckcraft-cli convert` writes the format OUT's extension names, or fails without writing.
+
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output};
+
+fn cli(args: &[&Path]) -> Output {
+ Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
+}
+
+fn temp_dir(name: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+#[test]
+fn convert_rejects_output_formats_it_cannot_write() {
+ let dir = temp_dir("convert-unsupported");
+ let input = dir.join("in.pptx");
+ let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", "--export"]).arg(&input).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+
+ for name in ["out.odp", "out.bmp", "out.svg", "out.outline", "out"] {
+ let target = dir.join(name);
+ let out = cli(&[Path::new("convert"), &input, &target]);
+ assert!(!out.status.success(), "{name}: convert should fail");
+ let err = String::from_utf8_lossy(&out.stderr);
+ assert!(err.contains("unsupported output format") && err.contains(".pptx"), "{name}: {err}");
+ assert!(!target.exists(), "{name}: nothing is written");
+ }
+
+ let target = dir.join("out.deckcraft");
+ let out = cli(&[Path::new("convert"), &input, &target]);
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ assert!(std::fs::read(&target).unwrap().starts_with(b"PK\x03\x04"));
+
+ let target = dir.join("out.txt");
+ let out = cli(&[Path::new("convert"), &input, &target]);
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ assert!(!std::fs::read(&target).unwrap().starts_with(b"PK"), "an outline is text, not a zip");
+
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn run_rejects_unsupported_save_and_export_paths_before_running() {
+ let dir = temp_dir("run-unsupported");
+ for flag in ["--save", "--export"] {
+ let target = dir.join("deck.odp");
+ let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", flag]).arg(&target).output().unwrap();
+ assert!(!out.status.success(), "{flag}: run should fail");
+ assert!(String::from_utf8_lossy(&out.stderr).contains("unsupported output format .odp"), "{flag}");
+ assert!(!target.exists(), "{flag}: nothing is written");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/apps/deckcraft-cli/tests/mcp_roots.rs b/apps/deckcraft-cli/tests/mcp_roots.rs
new file mode 100644
index 00000000..e9e1f856
--- /dev/null
+++ b/apps/deckcraft-cli/tests/mcp_roots.rs
@@ -0,0 +1,118 @@
+//! `deckcraft-cli mcp` with the automation root flags, run as the real binary.
+// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use std::io::Write;
+use std::path::PathBuf;
+use std::process::{Command, Output, Stdio};
+
+use serde_json::Value;
+
+fn bin() -> Command {
+ Command::new(env!("CARGO_BIN_EXE_deckcraft-cli"))
+}
+
+fn run(args: &[&str]) -> Output {
+ bin().args(args).output().unwrap()
+}
+
+fn tmpdir(tag: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("dc-cli-{tag}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+/// Send `calls` (tool name, arguments) to `deckcraft-cli mcp `; returns whether it exited
+/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
+fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec) {
+ let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
+ {
+ let stdin = child.stdin.as_mut().unwrap();
+ writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
+ for (i, (name, arguments)) in calls.iter().enumerate() {
+ let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
+ writeln!(stdin, "{call}").unwrap();
+ }
+ }
+ let out = child.wait_with_output().unwrap();
+ let stdout = String::from_utf8(out.stdout).unwrap();
+ let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
+ (out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies)
+}
+
+fn text(reply: &Value) -> &str {
+ reply["result"]["content"][0]["text"].as_str().unwrap()
+}
+
+#[test]
+fn mcp_automation_roots_confine_the_server() {
+ let base = tmpdir("roots");
+ let (work, elsewhere) = (base.join("work"), base.join("elsewhere"));
+ std::fs::create_dir_all(&work).unwrap();
+ std::fs::create_dir_all(&elsewhere).unwrap();
+ std::fs::write(work.join("in.txt"), "Inside text").unwrap();
+ std::fs::write(elsewhere.join("secret.txt"), "TOP SECRET").unwrap();
+ let work_s = work.to_str().unwrap();
+ let secret = elsewhere.join("secret.txt").to_str().unwrap().to_string();
+
+ // Both spellings of the flags; inside works, outside is refused with a readable message.
+ let read_flag = format!("--automation-read-root={work_s}");
+ let calls = [
+ ("open_presentation", serde_json::json!({"path": work.join("in.txt").to_str().unwrap()})),
+ ("open_presentation", serde_json::json!({"path": secret})),
+ ("save_presentation", serde_json::json!({"path": work.join("saved.deckcraft").to_str().unwrap()})),
+ ("save_presentation", serde_json::json!({"path": elsewhere.join("saved.deckcraft").to_str().unwrap()})),
+ ];
+ let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls);
+ assert!(success, "{stderr}");
+ assert!(stderr.contains("files confined"), "{stderr}");
+ assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
+ assert_eq!(replies[1]["result"]["isError"], true);
+ assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1]));
+ assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
+ assert!(work.join("saved.deckcraft").exists());
+ assert_eq!(replies[3]["result"]["isError"], true);
+ assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3]));
+ assert!(!elsewhere.join("saved.deckcraft").exists());
+
+ // A root left out grants none of its access.
+ let save = [("new_presentation", serde_json::json!({})), calls[2].clone()];
+ let (_, _, replies) = mcp_session(&[&read_flag], &save);
+ assert_eq!(replies[1]["result"]["isError"], true);
+ assert!(text(&replies[1]).contains("write authority is absent"), "{}", text(&replies[1]));
+
+ // No flags: nothing is confined.
+ let (success, _, replies) = mcp_session(&[], &calls[1..2]);
+ assert!(success);
+ assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
+ let _ = std::fs::remove_dir_all(base);
+}
+
+#[test]
+fn mcp_automation_root_flags_are_checked_up_front() {
+ let base = tmpdir("roots-args");
+ let dir = base.to_str().unwrap();
+ let missing = base.join("missing");
+ let fails = |args: &[&str], needle: &str| {
+ let mut full = vec!["mcp"];
+ full.extend_from_slice(args);
+ let o = run(&full);
+ assert!(!o.status.success(), "{args:?}");
+ let err = String::from_utf8_lossy(&o.stderr).into_owned();
+ assert!(err.contains(needle), "{args:?}: {err}");
+ };
+ // The flags confine the in-process server, so they cannot be combined with --connect.
+ fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
+ fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
+ fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
+ fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
+ fails(&["--automation-read-root"], "needs a folder");
+ fails(&["--automation-write-root", "--sample"], "needs a folder");
+ fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
+ fails(&["--automation-write-root="], "needs a non-empty folder");
+ // A file is not a folder.
+ std::fs::write(base.join("f.txt"), "x").unwrap();
+ fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root");
+ let _ = std::fs::remove_dir_all(base);
+}
diff --git a/apps/deckcraft-web/src/main.rs b/apps/deckcraft-web/src/main.rs
index 366ea346..6d2b7e94 100644
--- a/apps/deckcraft-web/src/main.rs
+++ b/apps/deckcraft-web/src/main.rs
@@ -7,7 +7,8 @@
//! Differences from the desktop app:
//! - no TCP control channel (browsers can't listen on sockets);
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
-//! asynchronously through `Services::inbox` (presentations open, media is inserted);
+//! asynchronously through `Services::inbox`, applied to the document, slide and operation
+//! that started the pick (a dismissed picker or a closed document drops the file);
//! - Save and Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - preferences are not persisted.
diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs
index f0199a55..dabad8af 100644
--- a/apps/deckcraft-web/src/web.rs
+++ b/apps/deckcraft-web/src/web.rs
@@ -1,7 +1,7 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use deckcraft_engine::Session;
-use deckcraft_ui_egui::{Inbox, Services, SlideApp};
+use deckcraft_ui_egui::{Inbox, InboxEvent, InboxFile, Services, SlideApp};
use wasm_bindgen::JsCast as _;
const PRESENTATION_EXTS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "txt", "md"];
@@ -77,16 +77,23 @@ impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
+ // Capture the document and slide now. The bytes arrive later, after the user may
+ // have switched slides.
+ let id = self.app.begin_external_file("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
- inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
+ push_inbox(&inbox, InboxEvent::File(InboxFile { id, name, bytes }));
+ ctx.request_repaint();
+ }
+ Err(e) => {
+ log::error!("couldn't read dropped file {name}: {e}");
+ push_inbox(&inbox, InboxEvent::Cancelled { id });
ctx.request_repaint();
}
- Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
@@ -107,7 +114,7 @@ impl eframe::App for WebShell {
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
Services {
- open_async: Some(Box::new(move |purpose: &str| {
+ open_async: Some(Box::new(move |purpose: &str, request: u64| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
let dialog = match purpose {
@@ -118,10 +125,12 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
};
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = dialog.pick_file().await else {
+ push_inbox(&inbox, InboxEvent::Cancelled { id: request });
+ ctx.request_repaint();
return;
};
let bytes = file.read().await;
- inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
+ push_inbox(&inbox, InboxEvent::File(InboxFile { id: request, name: file.file_name(), bytes }));
ctx.request_repaint();
});
})),
@@ -136,6 +145,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
}
}
+fn push_inbox(inbox: &Inbox, event: InboxEvent) {
+ inbox.lock().unwrap_or_else(|e| e.into_inner()).push(event);
+}
+
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
@@ -168,6 +181,7 @@ fn mime_for(name: &str) -> &'static str {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
+ Some("gif") => "image/gif",
Some("txt") => "text/plain",
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
Some("deckcraft") => "application/zip",
diff --git a/apps/deckcraft/src/audio.rs b/apps/deckcraft/src/audio.rs
index 53aa48cc..afa74a6a 100644
--- a/apps/deckcraft/src/audio.rs
+++ b/apps/deckcraft/src/audio.rs
@@ -1,6 +1,7 @@
//! cpal audio output for media playback: the device pulls mixed samples from the
//! `deckcraft_media::Player`, which makes it the playback clock video frames follow.
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
@@ -29,6 +30,8 @@ fn take_stream_error(slot: &ErrorSlot) -> Option {
pub struct CpalOut {
stream: Option,
error: ErrorSlot,
+ /// Set by the realtime error callback when the device went away: the stream is dead.
+ dead: Arc,
}
impl CpalOut {
@@ -56,7 +59,14 @@ impl AudioOut for CpalOut {
let rate = cfg.sample_rate().0;
let config: cpal::StreamConfig = cfg.clone().into();
let slot = self.error.clone();
- let err = move |e: cpal::StreamError| keep_stream_error(&slot, &e);
+ let dead = Arc::new(AtomicBool::new(false));
+ self.dead = dead.clone();
+ let err = move |e: cpal::StreamError| {
+ if matches!(e, cpal::StreamError::DeviceNotAvailable) {
+ dead.store(true, Ordering::Relaxed);
+ }
+ keep_stream_error(&slot, &e);
+ };
let stream = match cfg.sample_format() {
cpal::SampleFormat::F32 => dev.build_output_stream(&config, move |buf: &mut [f32], _| fill(buf, channels, rate), err, None),
cpal::SampleFormat::I16 => {
@@ -86,6 +96,13 @@ impl AudioOut for CpalOut {
self.stream = None;
self.report();
}
+
+ fn failure(&mut self) -> Option {
+ if !self.dead.swap(false, Ordering::Relaxed) {
+ return None;
+ }
+ Some(take_stream_error(&self.error).unwrap_or_else(|| "the audio device is no longer available".to_string()))
+ }
}
#[cfg(test)]
diff --git a/apps/deckcraft/src/control_server.rs b/apps/deckcraft/src/control_server.rs
index f50de025..966657b0 100644
--- a/apps/deckcraft/src/control_server.rs
+++ b/apps/deckcraft/src/control_server.rs
@@ -1,39 +1,147 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`deckcraft-cli mcp --connect`) wraps.
+//!
+//! Every resource a client can hold is bounded ([`Limits`]): simultaneous connections, request
+//! line length, queued requests, and how long a socket may sit idle or blocked on a write.
-use std::io::{BufRead, BufReader, Write};
+use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};
-use std::sync::mpsc::{Receiver, Sender, channel};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::time::Duration;
use deckcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
+/// Resource bounds for the control server.
+#[derive(Clone, Copy, Debug)]
+pub struct Limits {
+ /// Connections served at once; more are told `busy` and closed.
+ pub max_clients: usize,
+ /// Longest request line in bytes (newline excluded); a longer one closes the connection.
+ pub max_line: usize,
+ /// Requests waiting for the UI at once; more are answered `busy`.
+ pub queue: usize,
+ /// How long a connection may go without sending a byte before it is closed.
+ pub idle_timeout: Duration,
+ /// How long a reply write may block before the connection is closed.
+ pub write_timeout: Duration,
+ /// How long a request waits for the UI's answer.
+ pub reply_timeout: Duration,
+}
+
+impl Default for Limits {
+ fn default() -> Self {
+ Self {
+ max_clients: 16,
+ // Picture and media inserts carry base64 payloads: room for files of about 190 MB.
+ max_line: 256 * 1024 * 1024,
+ queue: 64,
+ idle_timeout: Duration::from_secs(600),
+ write_timeout: Duration::from_secs(30),
+ reply_timeout: Duration::from_secs(60),
+ }
+ }
+}
+
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,
+ match TcpListener::bind(("127.0.0.1", port)) {
+ Ok(l) => {
+ log::info!("control server listening on 127.0.0.1:{port}");
+ serve_listener(l, Limits::default(), ctx)
+ }
Err(e) => {
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
- return rx;
+ sync_channel::(0).1
}
- };
- log::info!("control server listening on 127.0.0.1:{port}");
+ }
+}
+
+/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
+fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver {
+ let (tx, rx) = sync_channel::(limits.queue.max(1));
+ let active = Arc::new(AtomicUsize::new(0));
std::thread::spawn(move || {
- for stream in listener.incoming().flatten() {
+ for mut stream in listener.incoming().flatten() {
+ let _ = stream.set_write_timeout(Some(limits.write_timeout));
+ if active.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
+ active.fetch_sub(1, Ordering::SeqCst);
+ let _ = writeln!(stream, "{}", json!({"ok": false, "error": "busy: too many control connections"}));
+ continue;
+ }
+ let slot = Slot(Arc::clone(&active));
let tx = tx.clone();
let ctx = ctx.clone();
- std::thread::spawn(move || serve(stream, tx, ctx));
+ let spawned = std::thread::Builder::new().name("control-client".into()).spawn(move || {
+ let _slot = slot;
+ serve(stream, &tx, &ctx, limits);
+ });
+ if spawned.is_err() {
+ log::warn!("control server could not start a client thread");
+ }
}
});
rx
}
-fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) {
+/// Holds one connection slot; gives it back when the client thread ends, however it ends.
+struct Slot(Arc);
+
+impl Drop for Slot {
+ fn drop(&mut self) {
+ self.0.fetch_sub(1, Ordering::SeqCst);
+ }
+}
+
+enum Line {
+ Text(Vec),
+ TooLong,
+ Closed,
+}
+
+/// Read one `\n`-terminated line of at most `max` bytes without buffering more than `max + 1`.
+fn read_line(r: &mut impl BufRead, max: usize) -> Line {
+ let mut buf = Vec::new();
+ let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
+ match r.by_ref().take(limit).read_until(b'\n', &mut buf) {
+ Ok(0) | Err(_) => Line::Closed,
+ Ok(_) => {
+ if buf.last() == Some(&b'\n') {
+ buf.pop();
+ Line::Text(buf)
+ } else if buf.len() > max {
+ Line::TooLong
+ } else {
+ // EOF inside a line: serve what arrived, the next read reports Closed.
+ Line::Text(buf)
+ }
+ }
+ }
+}
+
+fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context, limits: Limits) {
+ if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
+ return;
+ }
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 };
+ let mut reader = BufReader::new(read);
+ loop {
+ let line = match read_line(&mut reader, limits.max_line) {
+ Line::Text(t) => t,
+ Line::TooLong => {
+ let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("request line longer than {} bytes", limits.max_line)}));
+ break;
+ }
+ Line::Closed => break,
+ };
+ let Ok(line) = String::from_utf8(line) else {
+ if writeln!(out, "{}", json!({"ok": false, "error": "bad JSON: not UTF-8"})).is_err() {
+ break;
+ }
+ continue;
+ };
if line.trim().is_empty() {
continue;
}
@@ -43,11 +151,14 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) {
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"}));
+ let mut r = match tx.try_send(req) {
+ Ok(()) => {
+ ctx.request_repaint();
+ rrx.recv_timeout(limits.reply_timeout).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}))
+ }
+ Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests"}),
+ Err(TrySendError::Disconnected(_)) => break,
+ };
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
@@ -55,8 +166,134 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) {
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
- if writeln!(out, "{reply}").is_err() {
+ if let Err(e) = writeln!(out, "{reply}") {
+ if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) {
+ log::debug!("control client stopped reading replies; closing");
+ }
break;
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::net::SocketAddr;
+ use std::time::Instant;
+
+ fn small() -> Limits {
+ Limits {
+ max_clients: 2,
+ max_line: 64,
+ queue: 1,
+ idle_timeout: Duration::from_millis(300),
+ write_timeout: Duration::from_millis(300),
+ reply_timeout: Duration::from_millis(300),
+ }
+ }
+
+ fn server(limits: Limits) -> (SocketAddr, Receiver) {
+ let l = TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = l.local_addr().unwrap();
+ (addr, serve_listener(l, limits, egui::Context::default()))
+ }
+
+ fn client(addr: SocketAddr) -> (BufReader, TcpStream) {
+ let s = TcpStream::connect(addr).unwrap();
+ s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
+ (BufReader::new(s.try_clone().unwrap()), s)
+ }
+
+ fn reply(r: &mut BufReader) -> Option {
+ let mut s = String::new();
+ match r.read_line(&mut s) {
+ Ok(0) | Err(_) => None,
+ Ok(_) => serde_json::from_str(&s).ok(),
+ }
+ }
+
+ /// Answer every request with its method name until the server goes away.
+ fn ui(rx: Receiver) {
+ std::thread::spawn(move || {
+ for req in rx {
+ let _ = req.reply.send(json!({"ok": true, "result": req.method}));
+ }
+ });
+ }
+
+ #[test]
+ fn request_round_trip() {
+ let (addr, rx) = server(small());
+ ui(rx);
+ let (mut r, mut w) = client(addr);
+ writeln!(w, r#"{{"id":7,"method":"ui.inspect"}}"#).unwrap();
+ let v = reply(&mut r).unwrap();
+ assert_eq!(v["id"], 7);
+ assert_eq!(v["result"], "ui.inspect");
+ }
+
+ #[test]
+ fn oversized_line_closes_connection() {
+ let (addr, rx) = server(small());
+ ui(rx);
+ let (mut r, mut w) = client(addr);
+ w.write_all(&[b'x'; 200]).unwrap();
+ let v = reply(&mut r).unwrap();
+ assert!(v["error"].as_str().unwrap().contains("longer than 64"));
+ assert!(reply(&mut r).is_none());
+ }
+
+ #[test]
+ fn idle_connection_is_closed() {
+ let (addr, rx) = server(small());
+ ui(rx);
+ let (mut r, mut w) = client(addr);
+ w.write_all(b"{\"id\":1,").unwrap(); // unterminated, then silence
+ let t = Instant::now();
+ assert!(reply(&mut r).is_none());
+ assert!(t.elapsed() < Duration::from_secs(4));
+ }
+
+ #[test]
+ fn connection_limit_refuses_then_frees_slot() {
+ let (addr, rx) = server(small());
+ ui(rx);
+ let a = client(addr);
+ let (mut br, mut bw) = client(addr);
+ // Make sure both are being served before the third arrives.
+ writeln!(bw, r#"{{"id":1,"method":"m"}}"#).unwrap();
+ assert!(reply(&mut br).is_some());
+ let (mut cr, _cw) = client(addr);
+ let v = reply(&mut cr).unwrap();
+ assert!(v["error"].as_str().unwrap().starts_with("busy"));
+ drop(a);
+ // The freed slot is reusable once the server notices the disconnect.
+ let deadline = Instant::now() + Duration::from_secs(4);
+ loop {
+ let (mut dr, mut dw) = client(addr);
+ writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap();
+ if reply(&mut dr).is_some_and(|v| v["ok"] == true) {
+ break;
+ }
+ assert!(Instant::now() < deadline, "slot never freed");
+ std::thread::sleep(Duration::from_millis(50));
+ }
+ }
+
+ #[test]
+ fn blocked_ui_times_out_and_full_queue_is_busy() {
+ let (addr, rx) = server(small());
+ // No UI: the first request fills the one-slot queue and times out; the second finds it full.
+ let (mut ar, mut aw) = client(addr);
+ let (mut br, mut bw) = client(addr);
+ writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap();
+ std::thread::sleep(Duration::from_millis(50));
+ writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap();
+ let b = reply(&mut br).unwrap();
+ assert!(b["error"].as_str().unwrap().starts_with("busy"));
+ assert_eq!(b["id"], 2);
+ let a = reply(&mut ar).unwrap();
+ assert_eq!(a["error"], "timeout");
+ drop(rx);
+ }
+}
diff --git a/apps/deckcraft/src/display.rs b/apps/deckcraft/src/display.rs
new file mode 100644
index 00000000..2a6eeca2
--- /dev/null
+++ b/apps/deckcraft/src/display.rs
@@ -0,0 +1,85 @@
+//! Which windowing system to start on, on Linux and the BSDs.
+//!
+//! DeckCraft prefers X11 when `DISPLAY` is set (see `main`). A stale `DISPLAY` (left over from
+//! an ended `ssh -X` session or a desktop with Xwayland turned off) used to make start-up fail
+//! with winit's "failed to open display" even though a Wayland compositor was running. When a
+//! local `DISPLAY` names an X server whose socket isn't there and Wayland is available, start on
+//! Wayland instead. With no display at all, say so plainly.
+
+use std::path::Path;
+
+/// The windowing system to ask winit for.
+#[derive(Debug, PartialEq, Eq)]
+pub enum Choice {
+ X11,
+ /// Let winit decide (Wayland when `WAYLAND_DISPLAY` is set).
+ Default,
+ /// Neither `DISPLAY` nor `WAYLAND_DISPLAY`: there is nothing to open a window on.
+ None,
+}
+
+/// The X server number of a local `DISPLAY` (`:1`, `:1.0`, `unix:1`); `None` for a remote one
+/// (`host:10.0`, as `ssh -X` sets) or one we can't read, which are left to X11 itself.
+fn local_display_number(display: &str) -> Option {
+ let rest = display.strip_prefix(':').or_else(|| display.strip_prefix("unix:"))?;
+ rest.split('.').next()?.parse().ok()
+}
+
+/// Choose from the environment. `socket_exists` checks an X socket path (the filesystem in
+/// [`choose_from_env`], a stub in tests).
+pub fn choose(display: Option<&str>, wayland: Option<&str>, force_wayland: bool, socket_exists: impl Fn(&Path) -> bool) -> Choice {
+ let display = display.filter(|d| !d.is_empty());
+ let wayland = wayland.is_some_and(|w| !w.is_empty());
+ match display {
+ None if wayland => Choice::Default,
+ None => Choice::None,
+ Some(_) if force_wayland && wayland => Choice::Default,
+ Some(d) => match local_display_number(d) {
+ Some(n) if wayland && !socket_exists(&Path::new("/tmp/.X11-unix").join(format!("X{n}"))) => {
+ log::warn!("DISPLAY={d} has no X server; starting on Wayland");
+ Choice::Default
+ }
+ _ => Choice::X11,
+ },
+ }
+}
+
+/// [`choose`] for this process.
+pub fn choose_from_env() -> Choice {
+ let display = std::env::var("DISPLAY").ok();
+ let wayland = std::env::var("WAYLAND_DISPLAY").ok();
+ choose(display.as_deref(), wayland.as_deref(), std::env::var_os("DECKCRAFT_WAYLAND").is_some(), |p| p.exists())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_stale_local_display_falls_back_to_wayland() {
+ let none = |_: &Path| false;
+ let all = |_: &Path| true;
+ assert_eq!(choose(Some(":1"), Some("wayland-0"), false, none), Choice::Default);
+ assert_eq!(choose(Some(":1.0"), Some("wayland-0"), false, none), Choice::Default);
+ assert_eq!(choose(Some("unix:0"), Some("wayland-0"), false, none), Choice::Default);
+ // A live X server, no Wayland, or a remote DISPLAY: X11 as before.
+ assert_eq!(choose(Some(":1"), Some("wayland-0"), false, all), Choice::X11);
+ assert_eq!(choose(Some(":1"), None, false, none), Choice::X11);
+ assert_eq!(choose(Some("host:10.0"), Some("wayland-0"), false, none), Choice::X11);
+ // The socket checked is the display's own.
+ assert_eq!(choose(Some(":3"), Some("w"), false, |p: &Path| p.ends_with("X3")), Choice::X11);
+ assert_eq!(choose(Some(":3"), Some("w"), false, |p: &Path| p.ends_with("X0")), Choice::Default);
+ }
+
+ #[test]
+ fn wayland_only_forced_and_no_display_at_all() {
+ let all = |_: &Path| true;
+ assert_eq!(choose(None, Some("wayland-0"), false, all), Choice::Default);
+ assert_eq!(choose(Some(""), Some("wayland-0"), false, all), Choice::Default);
+ assert_eq!(choose(Some(":0"), Some("wayland-0"), true, all), Choice::Default);
+ // Forcing Wayland without a compositor keeps X11 rather than failing.
+ assert_eq!(choose(Some(":0"), None, true, all), Choice::X11);
+ assert_eq!(choose(None, None, false, all), Choice::None);
+ assert_eq!(choose(Some(""), Some(""), false, all), Choice::None);
+ }
+}
diff --git a/apps/deckcraft/src/graphics.rs b/apps/deckcraft/src/graphics.rs
index ccd0c459..fc054c06 100644
--- a/apps/deckcraft/src/graphics.rs
+++ b/apps/deckcraft/src/graphics.rs
@@ -1,10 +1,14 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
-use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
+use eframe::{
+ NativeOptions,
+ egui_wgpu::WgpuSetup,
+ wgpu::{Backends, Dx12Compiler},
+};
-/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
-/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
-pub fn configure(options: &mut NativeOptions, backend_override: Option) {
+/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit
+/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence.
+pub fn configure(options: &mut NativeOptions, backend_override: Option, compiler_override: Option) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
@@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
// workaround without mutating process environment variables.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
+ // Auto loads whichever dxcompiler.dll the DLL search path finds; DeckCraft ships
+ // none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's
+ // indirect-validation pipeline and loses the device before the first frame
+ // (issue #153). FXC uses the compiler that is part of Windows.
+ create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc);
+ log::info!(
+ "Windows graphics: backends={:?}, DX12 compiler={:?}",
+ create.instance_descriptor.backends,
+ create.instance_descriptor.backend_options.dx12.shader_compiler
+ );
}
}
@@ -21,7 +35,7 @@ mod tests {
fn configured_backends(backend_override: Option) -> Backends {
let mut options = NativeOptions::default();
- configure(&mut options, backend_override);
+ configure(&mut options, backend_override, None);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
@@ -38,6 +52,38 @@ mod tests {
assert_eq!(configured_backends(None), Backends::DX12);
}
+ #[test]
+ fn windows_default_uses_fxc_instead_of_searching_for_dxc() {
+ let mut options = NativeOptions::default();
+ if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
+ // Auto can find an unrelated DXC without its matching DXIL runtime (issue #153).
+ create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto;
+ }
+ configure(&mut options, None, None);
+ let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
+ panic!("default native options must create a wgpu instance");
+ };
+ assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
+ }
+
+ #[test]
+ fn explicit_compiler_override_is_preserved() {
+ for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] {
+ let mut options = NativeOptions::default();
+ configure(&mut options, None, Some(compiler.clone()));
+ let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
+ panic!("default native options must create a wgpu instance");
+ };
+ match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) {
+ (Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {}
+ (Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => {
+ assert_eq!(actual, expected);
+ }
+ (expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"),
+ }
+ }
+ }
+
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs
index 7b5e48bb..ef80daaa 100644
--- a/apps/deckcraft/src/main.rs
+++ b/apps/deckcraft/src/main.rs
@@ -1,6 +1,7 @@
//! DeckCraft desktop app.
//!
//! Usage: `deckcraft [--control ] [--sample] [--show] [files…]`
+//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits.
//!
//! `--control ` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
@@ -14,9 +15,13 @@
mod audio;
mod control_server;
+#[cfg(all(unix, not(target_os = "macos")))]
+mod display;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
+#[cfg(not(target_arch = "wasm32"))]
+mod pasteboard;
use deckcraft_engine::Session;
use deckcraft_ui_egui::{Services, SlideApp};
@@ -35,6 +40,16 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
save_prefs(&self.0);
+ // macOS (#279): end the process here, before eframe tears the window down. AccessKit's
+ // macOS adapter subclasses the window's content view and restores its class on teardown;
+ // AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
+ // `-[_NSTouchBarFinderObservation invalidate]` throws "not registered" and the quit ends
+ // in a crash report. eframe calls `on_exit` on every way of quitting (⌘Q, the close
+ // button, `app.quit`), once the unsaved-changes prompt has let the window close, and
+ // nothing after it is needed: the preferences are saved and the log file is unbuffered.
+ // `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117.
+ #[cfg(target_os = "macos")]
+ std::process::exit(0);
}
}
@@ -52,24 +67,16 @@ fn config_dir() -> Option {
}
}
-fn prefs_path() -> Option {
- config_dir().map(|b| b.join("ui.json"))
-}
-
fn load_prefs(app: &mut SlideApp) {
if std::env::var_os("DECKCRAFT_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)
- {
+ let Some(dir) = config_dir() else { return };
+ let (ui, prefs) = deckcraft_ui_egui::settings::load(&dir);
+ if let Some(ui) = ui {
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)
- {
+ if let Some(prefs) = prefs {
app.session.prefs = prefs;
}
}
@@ -78,14 +85,8 @@ fn save_prefs(app: &SlideApp) {
if std::env::var_os("DECKCRAFT_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);
- }
+ if let Some(dir) = config_dir() {
+ deckcraft_ui_egui::settings::save(&dir, &app.ui, &app.session.prefs);
}
}
@@ -100,6 +101,7 @@ fn services() -> Services {
&["wav", "mp3", "m4a", "m4b", "aac", "flac", "ogg", "oga", "opus", "aif", "aiff", "aifc", "caf", "wma", "weba", "mka"],
),
"video" => d.add_filter("Video", &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]),
+ "theme" => d.add_filter("Themes and presentations", &["thmx", "pptx", "potx", "ppsx"]).add_filter("Office Theme", &["thmx"]),
_ => d
.add_filter("Presentations", &["deckcraft", "pptx", "potx", "ppsx"])
.add_filter("DeckCraft", &["deckcraft"])
@@ -111,14 +113,9 @@ fn services() -> Services {
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)
- })),
+ clipboard_image: Some(Box::new(pasteboard::image_png)),
+ clipboard_html: Some(Box::new(pasteboard::html)),
+ clipboard_set_html: Some(Box::new(pasteboard::set_html)),
// Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently.
audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO")
.is_none()
@@ -129,6 +126,41 @@ fn services() -> Services {
const APP_ID: &str = "ai.storyteller.deckcraft";
+const USAGE: &str = "\
+Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...]
+
+ --control PORT JSON control server on 127.0.0.1:PORT
+ --sample open the sample presentation
+ --show start the slide show
+ --help, -h print this help and exit
+ --version, -V print the version and exit
+";
+
+enum Startup {
+ Help,
+ Version,
+ Run { control_port: Option, files: Vec, sample: bool, show: bool },
+}
+
+/// `--help` and `--version` leave before the window and the log file.
+fn startup(mut control_port: Option, args: impl IntoIterator) -> Startup {
+ let mut files = Vec::new();
+ let mut sample = false;
+ let mut show = false;
+ let mut args = args.into_iter();
+ 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" | "-V" => return Startup::Version,
+ "--help" | "-h" => return Startup::Help,
+ _ => files.push(a),
+ }
+ }
+ Startup::Run { control_port, files, sample, show }
+}
+
fn app_icon() -> Option {
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png");
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
@@ -138,26 +170,21 @@ fn main() -> eframe::Result {
// First, so every start-up record (and the engine's panic hook, installed with the first
// Session) is captured; see `logging`.
let logger = logging::install();
- let mut control_port: Option = std::env::var("DECKCRAFT_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" => {
+ let (control_port, files, sample, show) =
+ match startup(std::env::var("DECKCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()), std::env::args().skip(1)) {
+ Startup::Help => {
+ print!("{USAGE}");
+ return Ok(());
+ }
+ Startup::Version => {
println!("deckcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
- _ => files.push(a),
- }
- }
+ Startup::Run { control_port, files, sample, show } => (control_port, files, sample, show),
+ };
log::info!("DeckCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
- // The log file lives under the settings directory; opened after the arguments, so `--version`
- // leaves no file behind. Records logged until now are written to it first.
+ // The log file lives under the settings directory; opened after the arguments, so `--help` and
+ // `--version` leave no file behind. Records logged until now are written to it first.
if let Some(logger) = logger {
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
Some(Ok(path)) => log::info!("log file {}", path.display()),
@@ -184,15 +211,26 @@ fn main() -> eframe::Result {
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("DECKCRAFT_WAYLAND").is_none() {
+ let x11 = match display::choose_from_env() {
+ display::Choice::X11 => true,
+ display::Choice::Default => false,
+ display::Choice::None => {
+ // winit's own error says little to someone who started DeckCraft from a console.
+ log::error!("no display: DISPLAY and WAYLAND_DISPLAY are not set");
+ eprintln!("DeckCraft needs a graphical session: neither DISPLAY nor WAYLAND_DISPLAY is set.");
+ false
+ }
+ };
+ #[cfg(all(unix, not(target_os = "macos")))]
+ if x11 {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
- // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
+ // Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
- graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
+ graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
eframe::run_native(
"DeckCraft",
options,
@@ -228,3 +266,47 @@ fn main() -> eframe::Result {
}),
)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// #279: on macOS the process ends inside `on_exit`, before eframe would tear the window down.
+ /// The check runs in a child (this test binary again), since on macOS it ends the process.
+ #[test]
+ fn on_exit_ends_the_process_on_macos() {
+ if std::env::var_os("DECKCRAFT_TEST_ON_EXIT").is_some() {
+ let mut app = App(SlideApp::new(Session::new(), Services::default()));
+ println!("calling on_exit");
+ eframe::App::on_exit(&mut app);
+ println!("on_exit returned");
+ return;
+ }
+ let out = std::process::Command::new(std::env::current_exe().unwrap())
+ .args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"])
+ .env("DECKCRAFT_TEST_ON_EXIT", "1")
+ .env("DECKCRAFT_NO_PREFS", "1")
+ .output()
+ .unwrap();
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ assert!(out.status.success(), "{stdout}");
+ assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}");
+ assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
+ }
+
+ #[test]
+ fn help_exits_instead_of_opening_a_file() {
+ assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
+ assert!(matches!(startup(None, ["-h".to_string()]), Startup::Help));
+ assert!(matches!(startup(None, ["talk.pptx".to_string(), "--help".to_string()]), Startup::Help));
+ assert!(matches!(startup(None, ["--version".to_string()]), Startup::Version));
+ let launch = startup(Some(9), ["--sample".to_string(), "--control".to_string(), "4242".to_string(), "talk.pptx".to_string()]);
+ assert!(matches!(launch, Startup::Run { .. }));
+ if let Startup::Run { control_port, files, sample, show } = launch {
+ assert_eq!(control_port, Some(4242));
+ assert_eq!(files, ["talk.pptx".to_string()]);
+ assert!(sample);
+ assert!(!show);
+ }
+ }
+}
diff --git a/apps/deckcraft/src/pasteboard.rs b/apps/deckcraft/src/pasteboard.rs
new file mode 100644
index 00000000..98626295
--- /dev/null
+++ b/apps/deckcraft/src/pasteboard.rs
@@ -0,0 +1,77 @@
+//! System pasteboard: HTML rich text, and a picture (TIFF on macOS, via arboard).
+//!
+//! Pages keeps bold, italic and underline when the pasteboard carries HTML plus the same plain
+//! words. A shape with no text is copied as an image by the UI; this module only reads a picture
+//! back (Keynote's TIFF) as PNG bytes.
+
+/// HTML currently on the pasteboard, when there is some.
+pub fn html() -> Option {
+ let mut cb = arboard::Clipboard::new().ok()?;
+ let html = cb.get().html().ok()?;
+ let html = html.trim();
+ if html.is_empty() { None } else { Some(html.to_string()) }
+}
+
+/// Write `html` and the plain alternative `text`. `false` when the pasteboard refuses it.
+pub fn set_html(html: &str, text: &str) -> bool {
+ let Ok(mut cb) = arboard::Clipboard::new() else { return false };
+ cb.set_html(html, Some(text)).is_ok()
+}
+
+/// The pasteboard picture as PNG bytes (TIFF on macOS is decoded by arboard).
+pub fn image_png() -> Option> {
+ let mut cb = arboard::Clipboard::new().ok()?;
+ let img = cb.get_image().ok()?;
+ let width = u32::try_from(img.width).ok()?;
+ let height = u32::try_from(img.height).ok()?;
+ let rgba = image::RgbaImage::from_raw(width, height, 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)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::borrow::Cow;
+
+ /// Puts HTML and a 1×1 picture on the macOS pasteboard and reads them back.
+ /// Restores whatever plain text was there. Other platforms return immediately.
+ #[test]
+ fn html_and_a_picture_round_trip_on_macos() {
+ if !cfg!(target_os = "macos") {
+ return;
+ }
+ let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok());
+ let _restore = Restore(previous);
+ assert!(super::set_html("DeckCraft", "DeckCraft"), "set html");
+ let html = super::html().unwrap_or_default();
+ assert!(html.contains("") || html.contains(""), "{html}");
+ let text = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()).unwrap_or_default();
+ assert_eq!(text, "DeckCraft");
+ let mut cb = arboard::Clipboard::new().expect("pasteboard");
+ let img = arboard::ImageData { width: 1, height: 1, bytes: Cow::Owned(vec![255, 0, 0, 255]) };
+ cb.set_image(img).expect("set image");
+ drop(cb);
+ let png = super::image_png().expect("png");
+ let decoded = image::load_from_memory(&png).expect("decode").to_rgba8();
+ let p = decoded.get_pixel(0, 0);
+ assert!(p[0] > 200 && p[1] < 40 && p[3] > 200, "{p:?}");
+ }
+
+ /// Puts the previous plain text back, including when the test fails halfway.
+ struct Restore(Option);
+
+ impl Drop for Restore {
+ fn drop(&mut self) {
+ let Ok(mut cb) = arboard::Clipboard::new() else { return };
+ match self.0.take() {
+ Some(t) => {
+ let _ = cb.set_text(t);
+ }
+ None => {
+ let _ = cb.clear();
+ }
+ }
+ }
+ }
+}
diff --git a/contributors/people.toml b/contributors/people.toml
index c10fb209..f70303a1 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -41,3 +41,16 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.dotCooCoo]
+real_name = "Robert Lee"
+sync_github_name = true
+
+[people.Momentum96]
+sync_github_name = true
+
+[people.NikitkaRa]
+real_name = "Nikita Rakitin"
+
+[people.RCShoemaker]
+real_name = "Ryan C. Shoemaker"
diff --git a/crates/anim/src/effects.rs b/crates/anim/src/effects.rs
index 37776f83..e51052d9 100644
--- a/crates/anim/src/effects.rs
+++ b/crates/anim/src/effects.rs
@@ -44,6 +44,15 @@ pub(crate) fn exit_base(effect: &str) -> &str {
"swivelOut" => "swivel",
"bounceOut" => "bounce",
"dissolveOut" => "dissolve",
+ "blindsOut" => "blinds",
+ "boxOut" => "box",
+ "checkerboardOut" => "checkerboard",
+ "diamondOut" => "diamond",
+ "peekOut" => "peek",
+ "stripsOut" => "strips",
+ "sinkDown" => "rise",
+ "spinnerOut" => "spinner",
+ "contract" => "expand",
other => other,
}
}
@@ -69,6 +78,7 @@ pub(crate) fn emphasis_persists(effect: &str) -> bool {
fn uses_default_smoothing(effect: &str) -> bool {
matches!(effect, "fly" | "float" | "rise" | "peek" | "lines" | "arcs" | "turns" | "shapes" | "loops" | "customPath")
+ || deckcraft_model::anim::preset_path(effect).is_some()
}
impl EffectSpec {
diff --git a/crates/anim/src/interact.rs b/crates/anim/src/interact.rs
new file mode 100644
index 00000000..e32d980e
--- /dev/null
+++ b/crates/anim/src/interact.rs
@@ -0,0 +1,266 @@
+//! Clicks in a slide show: which shape a click lands on, what it starts (a triggered animation, a
+//! link, the next step) and what the show remembers about each slide it has visited.
+//!
+//! The rules follow PowerPoint, as observed on its slide show:
+//! - the topmost visible shape under the pointer decides; a shape without an action on top of one
+//! with an action blocks it (the click then counts as a click on the background);
+//! - a shape with a triggered animation plays the next step of it per click, and starts over once all
+//! its steps have played; clicking it while a step plays completes the step at once;
+//! - a shape with a link and a triggered animation follows the link at once. The animation's clock
+//! runs only while its slide is shown, so it goes on where it stopped when the show returns;
+//! - with "advance on click" off a click never leaves the slide, but it still plays animation steps;
+//! - a slide the show returns to (link or arrow keys) looks as it was left.
+
+use deckcraft_model::text::Action;
+use deckcraft_model::{Presentation, ShapeId};
+
+use crate::{AnimState, LinkJump, ShowAction, ShowState, StepStamp, Timeline, TriggerPlay};
+
+/// What was played on one slide.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub(crate) struct SlideRun {
+ /// The show has been on this slide.
+ pub visited: bool,
+ /// Main-sequence steps played when the show left the slide.
+ pub step: usize,
+ /// Triggered sequences started by clicks.
+ pub triggers: Vec,
+ /// Steps of triggered sequences started so far (the stamps of [`StepStamp`]).
+ pub clock: u64,
+}
+
+/// Progress of one triggered sequence.
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct TriggerRun {
+ pub shape: ShapeId,
+ /// Clicks whose step has played to its end.
+ pub done: usize,
+ /// Seconds the current step has played; `None` when no step is playing.
+ pub elapsed: Option,
+ /// When each step of this run was started, by step.
+ pub stamps: Vec,
+}
+
+/// One shape of the slide for hit testing, in drawing order (back to front; a group before its
+/// children), with whether the pointer is inside its box.
+#[derive(Clone, Debug, PartialEq)]
+pub struct HitShape {
+ pub id: ShapeId,
+ /// Index of the enclosing group in the same list.
+ pub parent: Option,
+ pub is_group: bool,
+ /// The shape's own action.
+ pub link: Option,
+ pub under_pointer: bool,
+}
+
+/// What the pointer is over: a shape that does something when clicked.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Target {
+ pub shape: ShapeId,
+ pub link: Option,
+}
+
+/// What a click did.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Click {
+ /// Nothing happened.
+ None,
+ /// A triggered animation started or was completed: redraw.
+ Animated,
+ /// Navigation inside the show (already applied to the state).
+ Show(ShowAction),
+ /// Open this URL outside the show.
+ Open(String),
+ /// End the show.
+ Exit,
+}
+
+impl From for Click {
+ fn from(j: LinkJump) -> Click {
+ match j {
+ LinkJump::Show(a) => Click::Show(a),
+ LinkJump::Open(u) => Click::Open(u),
+ LinkJump::Exit => Click::Exit,
+ LinkJump::None => Click::None,
+ }
+ }
+}
+
+fn finite_or_zero(v: f64) -> f64 {
+ if v.is_finite() { v } else { 0.0 }
+}
+
+impl ShowState {
+ fn animated(&self, pres: &Presentation) -> bool {
+ !pres.show.without_animation
+ }
+
+ /// Does clicking `shape` start a triggered sequence? A sequence can be started again once it has
+ /// played to its end.
+ fn trigger_live(&self, pres: &Presentation, tl: &Timeline, shape: ShapeId) -> bool {
+ self.animated(pres) && tl.trigger_steps(shape) > 0
+ }
+
+ /// The playheads of the current slide's triggered sequences at the last tick, for
+ /// [`Timeline::state_with`].
+ pub fn trigger_plays(&self) -> Vec {
+ match self.runs.get(&self.slide) {
+ Some(r) => r.triggers.iter().map(|t| TriggerPlay { trigger: t.shape, step: t.done, t: t.elapsed.unwrap_or(0.0) }).collect(),
+ None => vec![],
+ }
+ }
+
+ /// When the steps of the current slide's triggered sequences were started.
+ pub fn trigger_stamps(&self) -> Vec {
+ let Some(r) = self.runs.get(&self.slide) else { return vec![] };
+ r.triggers.iter().flat_map(|t| t.stamps.iter().enumerate().map(|(step, &at)| StepStamp { trigger: t.shape, step, at })).collect()
+ }
+
+ /// Is a triggered animation of the current slide playing (so the picture changes every frame)?
+ pub fn trigger_playing(&self) -> bool {
+ self.runs.get(&self.slide).is_some_and(|r| r.triggers.iter().any(|t| t.elapsed.is_some()))
+ }
+
+ /// Animation state of `shape` on the current slide: main step `step` with `t_main` seconds
+ /// played, plus the triggered sequences.
+ pub fn anim_state(&self, tl: &Timeline, shape: ShapeId, t_main: f64) -> AnimState {
+ tl.state_ordered(shape, None, self.step, t_main, &self.trigger_plays(), &self.trigger_stamps())
+ }
+
+ /// Advance the clock of the playing triggered animations to `now` (seconds, any steady clock)
+ /// and complete the steps that reached their end. [`Click::Animated`] when the picture changed.
+ pub fn tick(&mut self, tl: &Timeline, now: f64) -> Click {
+ let dt = match self.last_now {
+ Some(last) if now.is_finite() => (now - last).clamp(0.0, 3600.0),
+ _ => 0.0,
+ };
+ if now.is_finite() {
+ self.last_now = Some(now);
+ }
+ let Some(run) = self.runs.get_mut(&self.slide) else { return Click::None };
+ let mut changed = false;
+ for t in run.triggers.iter_mut() {
+ let Some(e) = t.elapsed else { continue };
+ let e = finite_or_zero(e) + dt;
+ if e >= tl.trigger_step_duration(t.shape, t.done) {
+ t.done += 1;
+ t.elapsed = None;
+ changed = true;
+ } else {
+ t.elapsed = Some(e);
+ }
+ }
+ if changed { Click::Animated } else { Click::None }
+ }
+
+ /// Index of the topmost shape a click lands on: the front-most one under the pointer that is
+ /// shown. A group without an action does not count; its children and the space inside its box
+ /// are not it.
+ pub fn top(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option {
+ let shown = |mut i: usize| -> bool {
+ // A shape is shown when it and the groups around it are.
+ for _ in 0..=shapes.len() {
+ let Some(h) = shapes.get(i) else { return false };
+ if !visible(h.id) {
+ return false;
+ }
+ match h.parent {
+ Some(p) => i = p,
+ None => return true,
+ }
+ }
+ false
+ };
+ (0..shapes.len())
+ .rev()
+ .find(|&i| shapes.get(i).is_some_and(|h| h.under_pointer && !(h.is_group && !self.interactive(pres, tl, h)) && shown(i)))
+ }
+
+ fn interactive(&self, pres: &Presentation, tl: &Timeline, h: &HitShape) -> bool {
+ h.link.is_some() || self.trigger_live(pres, tl, h.id)
+ }
+
+ /// The shape under the pointer that a click would act on, `None` when a click would be a click
+ /// on the background. `visible` tells whether a shape is currently shown (not yet entered or
+ /// already exited shapes cannot be clicked).
+ pub fn target(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option {
+ // The topmost shape, or the group it is in, that has an action. A shape without one blocks
+ // what lies beneath it.
+ let mut at = self.top(pres, tl, shapes, visible);
+ for _ in 0..=shapes.len() {
+ let h = shapes.get(at?)?;
+ if self.interactive(pres, tl, h) {
+ return Some(Target { shape: h.id, link: h.link.clone() });
+ }
+ at = h.parent;
+ }
+ None
+ }
+
+ /// A mouse click. `shapes` and `visible` describe what is under the pointer (see
+ /// [`ShowState::target`]); `last_viewed` is the slide shown before this one.
+ pub fn click(
+ &mut self,
+ pres: &Presentation,
+ tl: &Timeline,
+ shapes: &[HitShape],
+ visible: &dyn Fn(ShapeId) -> bool,
+ now: f64,
+ last_viewed: Option,
+ ) -> Click {
+ self.tick(tl, now);
+ if self.ended {
+ return Click::None;
+ }
+ let Some(t) = self.target(pres, tl, shapes, visible) else { return self.background_click(pres) };
+ let mut animated = false;
+ if self.trigger_live(pres, tl, t.shape) {
+ let slide = self.slide;
+ let run = self.runs.entry(slide).or_default();
+ let at = match run.triggers.iter().position(|r| r.shape == t.shape) {
+ Some(p) => p,
+ None => {
+ run.triggers.push(TriggerRun { shape: t.shape, done: 0, elapsed: None, stamps: vec![] });
+ run.triggers.len() - 1
+ }
+ };
+ let stamp = run.clock.saturating_add(1);
+ if let Some(r) = run.triggers.get_mut(at) {
+ if r.elapsed.is_some() {
+ // A click on a shape whose step is playing completes the step.
+ r.done += 1;
+ r.elapsed = None;
+ } else {
+ if r.done >= tl.trigger_steps(t.shape) {
+ // All steps have played: the sequence starts over.
+ r.done = 0;
+ r.stamps.clear();
+ }
+ r.stamps.push(stamp);
+ run.clock = stamp;
+ if tl.trigger_step_duration(t.shape, r.done) > 0.0 {
+ r.elapsed = Some(0.0);
+ } else {
+ r.done += 1;
+ }
+ }
+ animated = true;
+ }
+ }
+ match t.link {
+ // The link is followed at once, whatever the animation still has to play.
+ Some(a) => self.follow(pres, &a, last_viewed).into(),
+ None if animated => Click::Animated,
+ None => Click::None,
+ }
+ }
+
+ /// A click that is on no shape with an action: next animation step, then next slide, unless the
+ /// slide has "advance on click" off, which leaves only the animation steps.
+ fn background_click(&mut self, pres: &Presentation) -> Click {
+ let advance = pres.slides.get(self.slide).and_then(|s| s.transition.as_ref()).is_none_or(|t| t.advance_on_click);
+ let steps_left = self.playing || self.step < ShowState::steps(pres, self.slide);
+ if advance || steps_left { Click::Show(self.next(pres)) } else { Click::None }
+ }
+}
diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs
index d94167ca..cf8fb3ff 100644
--- a/crates/anim/src/lib.rs
+++ b/crates/anim/src/lib.rs
@@ -5,7 +5,8 @@
//! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images.
//! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by
//! [`morph_xfrm`]).
-//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings).
+//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings), what was
+//! played on each visited slide, and what a click does ([`ShowState::click`]).
//!
//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or
//! negative times) and never panics.
@@ -14,6 +15,7 @@
mod easing;
mod effects;
+mod interact;
mod mesh;
mod morph;
mod path;
@@ -26,17 +28,20 @@ mod transition_geom;
#[cfg(test)]
mod tests;
#[cfg(test)]
+mod tests_interact;
+#[cfg(test)]
mod transition_tests;
pub use deckcraft_model::Rgba;
use serde::{Deserialize, Serialize};
pub use easing::ease;
+pub use interact::{Click, HitShape, Target};
pub use mesh::{MeshVertex, layer_mesh};
-pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm};
+pub use morph::{MorphFrame, MorphPath, MorphText, morph_crop, morph_frame, morph_pairs, morph_xfrm};
pub use path::{MotionPath, default_path};
pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order};
-pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay};
+pub use timeline::{EffectTiming, Sequence, StepStamp, Timeline, TriggerPlay};
pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition_layers, uses_slide_parts};
/// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show.
diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs
index b5808c4f..a869ebdb 100644
--- a/crates/anim/src/morph.rs
+++ b/crates/anim/src/morph.rs
@@ -12,7 +12,9 @@ fn text_of(s: &Shape) -> String {
/// 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).
+/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
+/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). 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()];
@@ -31,7 +33,8 @@ pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
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()
+ && text_of(x).trim().is_empty()
+ && text_of(y).trim().is_empty()
},
];
for pass in passes {
@@ -173,9 +176,58 @@ fn reshaped(a: &Shape, b: &Shape) -> bool {
a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) }
}
+/// The content of a shape apart from what Morph interpolates, with a picture's crop left out.
+fn uncropped(s: &Shape) -> Shape {
+ let mut c = content(s);
+ if let ShapeKind::Picture { fill } = &mut c.kind {
+ fill.crop = [0.0; 4];
+ }
+ c
+}
+
+fn crop_of(s: &Shape) -> [f64; 4] {
+ match &s.kind {
+ ShapeKind::Picture { fill } => fill.crop,
+ _ => [0.0; 4],
+ }
+}
+
+/// Whether two pictures differ in their crop only (apart from box and fill), so the crop can morph.
+fn recropped(a: &Shape, b: &Shape) -> bool {
+ matches!(a.kind, ShapeKind::Picture { .. }) && crop_of(a) != crop_of(b) && uncropped(a) == uncropped(b)
+}
+
+/// The crop (left, top, right, bottom) of a picture morphing from box `xa` cropped by `ca` to box
+/// `xb` cropped by `cb`, when its box is `x` at progress `t`: the whole image moves and scales
+/// linearly between where it lies on either slide, and the crop is what of it `x` shows. Falls back
+/// to blending the crops when a crop leaves nothing visible.
+pub fn morph_crop(ca: [f64; 4], xa: Xfrm, cb: [f64; 4], xb: Xfrm, x: Xfrm, t: f64) -> [f64; 4] {
+ // Along one axis: (box start, box size, crop before, crop after) → the crops at `t`.
+ let axis = |(pa, sa, la, ha): (f64, f64, f64, f64), (pb, sb, lb, hb): (f64, f64, f64, f64), (p, s): (f64, f64)| {
+ let (va, vb) = (1.0 - la - ha, 1.0 - lb - hb);
+ if va <= 1e-6 || vb <= 1e-6 {
+ return (lerp(la, lb, t), lerp(ha, hb, t));
+ }
+ // The whole image: its extent and start on either slide.
+ let (ea, eb) = (sa / va, sb / vb);
+ let e = lerp(ea, eb, t);
+ if !e.is_finite() || e.abs() <= 1e-9 {
+ return (lerp(la, lb, t), lerp(ha, hb, t));
+ }
+ let o = lerp(pa - la * ea, pb - lb * eb, t);
+ let lo = (p - o) / e;
+ (lo, 1.0 - lo - s / e)
+ };
+ let (l, r) = axis((xa.x, xa.w, ca[0], ca[2]), (xb.x, xb.w, cb[0], cb[2]), (x.x, x.w));
+ let (tp, bt) = axis((xa.y, xa.h, ca[1], ca[3]), (xb.y, xb.h, cb[1], cb[3]), (x.y, x.h));
+ let c = [l, tp, r, bt];
+ if c.iter().all(|v| v.is_finite()) { c } else { cb }
+}
+
/// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes
/// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the
-/// colour blends, when only in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
+/// colour blends, when pictures differ only in crop the crop morphs ([`morph_crop`]), when only
+/// in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
/// otherwise the new shape fades in under the fading old one on the same moving
/// box; with the transition option `words` or `characters` their texts are listed in
/// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for
@@ -223,8 +275,18 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
(_, xb) => xb,
};
let reshape = reshaped(sa, sb);
- if reshape || content(sa) == content(sb) {
+ let recrop = recropped(sa, sb);
+ if reshape || recrop || content(sa) == content(sb) {
let mut m = Shape { xfrm: x, ..sb.clone() };
+ if recrop && let ShapeKind::Picture { fill } = &mut m.kind {
+ fill.crop = match (xa, xb, x) {
+ (Some(xa), Some(xb), Some(x)) => morph_crop(crop_of(sa), xa, crop_of(sb), xb, x, s),
+ _ => {
+ let (ca, cb) = (crop_of(sa), crop_of(sb));
+ [lerp(ca[0], cb[0], s), lerp(ca[1], cb[1], s), lerp(ca[2], cb[2], s), lerp(ca[3], cb[3], s)]
+ }
+ };
+ }
if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb))
&& fa != fb
{
@@ -237,7 +299,11 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
} else {
let new = push(Shape { xfrm: x, ..sb.clone() }, false, s);
let old = push(Shape { xfrm: x, ..sa.clone() }, true, 1.0 - s);
- if by_text
+ // The same text in shapes that differ otherwise (a title slide's centred title and a
+ // content slide's title: other placeholder, size, alignment) moves and resizes word
+ // by word even with the Objects option, as PowerPoint does, instead of cross-fading.
+ let same_text = text_of(sa).trim() == text_of(sb).trim();
+ if (by_text || same_text)
&& has_text(sa)
&& has_text(sb)
&& let (Some(from), Some(to)) = (box_of(ca.as_ref(), sa), box_of(cb.as_ref(), sb))
diff --git a/crates/anim/src/path.rs b/crates/anim/src/path.rs
index ffe4f5e6..af61bd58 100644
--- a/crates/anim/src/path.rs
+++ b/crates/anim/src/path.rs
@@ -288,6 +288,27 @@ pub fn default_path(effect: &str, option: &str) -> String {
}
},
"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(),
+ other => deckcraft_model::anim::preset_path(other).unwrap_or_default(),
+ }
+}
+
+#[cfg(test)]
+mod preset_path_tests {
+ use super::*;
+
+ #[test]
+ fn every_extra_motion_path_preset_starts_at_its_shape_and_moves() {
+ use deckcraft_model::anim::{ANIMATIONS, AnimClass, preset_path};
+ let extra: Vec<_> = ANIMATIONS.iter().filter(|a| a.2 == AnimClass::Path && preset_path(a.0).is_some()).collect();
+ assert!(extra.len() >= 30);
+ for a in extra {
+ assert_eq!(default_path(a.0, ""), preset_path(a.0).unwrap_or_default());
+ let mp = MotionPath::parse(&default_path(a.0, ""));
+ let (x, y) = mp.at(0.0);
+ assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "{} starts at its shape", a.0);
+ let far = mp.points.iter().map(|p| p.0.abs() + p.1.abs()).fold(0.0, f64::max);
+ assert!(far > 0.05, "{} moves", a.0);
+ assert!(mp.points.iter().all(|p| p.0.abs() < 0.5 && p.1.abs() < 0.5), "{} stays near its shape", a.0);
+ }
}
}
diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs
index dbc0a8ae..3d9c241f 100644
--- a/crates/anim/src/show.rs
+++ b/crates/anim/src/show.rs
@@ -1,10 +1,13 @@
//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop
//! and automatic advance.
+use std::collections::BTreeMap;
+
use deckcraft_model::Presentation;
-use deckcraft_model::text::Action;
+use deckcraft_model::text::{Action, Hyperlink};
use crate::Timeline;
+use crate::interact::SlideRun;
/// What the UI should do after a navigation call.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -52,8 +55,10 @@ pub enum LinkJump {
None,
}
-/// Where a slide show is.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
+/// Where a slide show is. Besides the current position it remembers what was played on every slide
+/// the show has visited (main steps, triggered sequences), so a slide reached again by a link or
+/// the arrow keys looks the way it was left, as in PowerPoint.
+#[derive(Clone, Debug, PartialEq, Default)]
pub struct ShowState {
/// Index into `presentation.slides`.
pub slide: usize,
@@ -63,6 +68,19 @@ pub struct ShowState {
pub playing: bool,
/// On the end screen.
pub ended: bool,
+ /// The show has entered a slide (so `slide` and `step` are meaningful and can be saved).
+ active: bool,
+ /// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks.
+ pub(crate) last_now: Option,
+ /// What was played on each visited slide (index into `presentation.slides`).
+ pub(crate) runs: BTreeMap,
+ /// One-shot visit order (Custom Show from the dialog or a click). `None` uses [`show_order`].
+ order: Option>,
+ /// Name of the custom show being played, when there is one.
+ custom: Option,
+ /// Return to Zoom: (zoom slide, last zoomed slide). Advancing past the last zoomed slide goes
+ /// back to the zoom slide, fully built.
+ pub zoom: Option<(usize, usize)>,
}
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
@@ -83,6 +101,18 @@ pub fn show_order(pres: &Presentation) -> Vec {
base.into_iter().filter(|i| pres.slides.get(*i).is_some_and(|s| !s.hidden)).collect()
}
+/// Visible slides of custom show `name`, in that show's order.
+fn custom_indices(pres: &Presentation, name: &str) -> Option> {
+ let cs = pres.custom_shows.iter().find(|c| c.name == name)?;
+ let order: Vec = cs
+ .slides
+ .iter()
+ .filter_map(|id| pres.slides.iter().position(|s| s.id == *id))
+ .filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden))
+ .collect();
+ if order.is_empty() { None } else { Some(order) }
+}
+
fn looping(pres: &Presentation) -> bool {
pres.show.loop_until_esc || pres.show.show_type == "kiosk"
}
@@ -104,9 +134,48 @@ impl ShowState {
} else {
s.ended = true;
}
+ if let Some(name) = pres.show.custom_show.clone() {
+ s.custom = Some(name);
+ }
s
}
+ /// Play custom show `name` from its first slide, without changing Set Up Slide Show.
+ /// `None` when that show is missing or has no visible slides.
+ pub fn play_custom(pres: &Presentation, name: &str) -> Option {
+ let order = custom_indices(pres, name)?;
+ let &first = order.first()?;
+ let mut s = ShowState::start_at(pres, first);
+ s.order = Some(order);
+ s.custom = Some(name.to_string());
+ Some(s)
+ }
+
+ /// Name of the custom show this playback is limited to.
+ pub fn custom_name(&self) -> Option<&str> {
+ self.custom.as_deref().filter(|s| !s.is_empty())
+ }
+
+ /// The slide that follows the current one in this show.
+ pub fn peek_next(&self, pres: &Presentation) -> Option {
+ self.next_index(pres)
+ }
+
+ pub fn first_index(&self, pres: &Presentation) -> Option {
+ self.visit(pres).first().copied()
+ }
+
+ pub fn last_index(&self, pres: &Presentation) -> Option {
+ self.visit(pres).last().copied()
+ }
+
+ fn visit(&self, pres: &Presentation) -> Vec {
+ match &self.order {
+ Some(order) => order.iter().copied().filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)).collect(),
+ None => show_order(pres),
+ }
+ }
+
/// The timeline of slide `i`.
pub fn timeline(pres: &Presentation, i: usize) -> Timeline {
match pres.slides.get(i) {
@@ -120,15 +189,41 @@ impl ShowState {
if pres.show.without_animation { 0 } else { ShowState::timeline(pres, i).steps() }
}
+ /// Save what was played on the current slide before the show moves away from it. A main step
+ /// that is playing is taken to its end. Triggered animations keep their place: their clock only
+ /// runs while the slide is shown, and they go on from there when the show comes back.
+ fn leave(&mut self, pres: &Presentation) {
+ if !self.active {
+ return;
+ }
+ let done = if self.playing { self.step + 1 } else { self.step };
+ let step = done.min(ShowState::steps(pres, self.slide));
+ self.runs.entry(self.slide).or_default().step = step;
+ self.playing = false;
+ }
+
+ /// Show slide `i`: as it was left when the show has been there before, otherwise from the start.
fn enter(&mut self, pres: &Presentation, i: usize) {
+ self.leave(pres);
+ self.active = true;
self.slide = i;
- self.step = 0;
self.ended = false;
- self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0);
+ // Time spent elsewhere never counts for this slide's triggered animations.
+ self.last_now = None;
+ let steps = ShowState::steps(pres, i);
+ let run = self.runs.entry(i).or_default();
+ if run.visited {
+ self.step = run.step.min(steps);
+ self.playing = false;
+ } else {
+ run.visited = true;
+ self.step = 0;
+ 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);
+ let order = self.visit(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),
@@ -136,7 +231,7 @@ impl ShowState {
}
fn prev_index(&self, pres: &Presentation) -> Option {
- let order = show_order(pres);
+ let order = self.visit(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),
@@ -160,12 +255,22 @@ impl ShowState {
self.playing = true;
return ShowAction::PlayStep(self.step);
}
+ if let Some((back, until)) = self.zoom
+ && until == self.slide
+ && back < pres.slides.len()
+ {
+ self.zoom = None;
+ self.enter(pres, back);
+ self.step = ShowState::steps(pres, back);
+ self.playing = false;
+ return ShowAction::Slide(back);
+ }
match self.next_index(pres) {
Some(i) => {
self.enter(pres, i);
ShowAction::Slide(i)
}
- None if looping(pres) => match show_order(pres).first() {
+ None if looping(pres) => match self.visit(pres).first() {
Some(&i) => {
self.enter(pres, i);
ShowAction::Slide(i)
@@ -215,14 +320,19 @@ impl ShowState {
}
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 if looping(pres) => self.visit(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;
+ // A slide the show has been to looks as it was left; one it has not been to is
+ // shown fully built.
+ let seen = self.runs.get(&i).is_some_and(|r| r.visited);
+ self.enter(pres, i);
+ if !seen {
+ self.step = ShowState::steps(pres, i);
+ self.playing = false;
+ }
ShowAction::Slide(i)
}
None => ShowAction::None,
@@ -234,6 +344,7 @@ impl ShowState {
if i >= pres.slides.len() {
return ShowAction::None;
}
+ self.zoom = None;
self.enter(pres, i);
ShowAction::Slide(i)
}
@@ -247,11 +358,18 @@ impl ShowState {
Action::Slide { slide } => pres.slides.iter().position(|s| s.id == *slide),
Action::NextSlide => self.next_index(pres),
Action::PreviousSlide => self.prev_index(pres),
- Action::FirstSlide => show_order(pres).first().copied(),
- Action::LastSlide => show_order(pres).last().copied(),
+ Action::FirstSlide => self.visit(pres).first().copied(),
+ Action::LastSlide => self.visit(pres).last().copied(),
Action::LastViewed => last_viewed,
- // Custom shows, programs and media actions aren't followed (yet).
- Action::CustomShow { .. } | Action::Program { .. } | Action::PlayMedia => None,
+ Action::CustomShow { name, .. } => {
+ let Some(order) = custom_indices(pres, name) else { return LinkJump::None };
+ let Some(&i) = order.first() else { return LinkJump::None };
+ self.order = Some(order);
+ self.custom = Some(name.clone());
+ return LinkJump::Show(self.goto(pres, i));
+ }
+ // Programs and media actions aren't followed.
+ Action::Program { .. } | Action::PlayMedia => None,
};
match target {
Some(i) => LinkJump::Show(self.goto(pres, i)),
@@ -259,6 +377,18 @@ impl ShowState {
}
}
+ /// Follow a clicked link, honouring a zoom's Return to Zoom (see [`Hyperlink::zoom_return`]).
+ pub fn follow_link(&mut self, pres: &Presentation, link: &Hyperlink, last_viewed: Option) -> LinkJump {
+ let from = self.slide;
+ let jump = self.follow(pres, &link.action, last_viewed);
+ if let Some(until) = link.zoom_return.and_then(|id| pres.slides.iter().position(|s| s.id == id))
+ && matches!(jump, LinkJump::Show(ShowAction::Slide(_)))
+ {
+ self.zoom = Some((from, until));
+ }
+ jump
+ }
+
/// 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 {
diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs
index 1fd82d78..1ac69104 100644
--- a/crates/anim/src/tests.rs
+++ b/crates/anim/src/tests.rs
@@ -529,6 +529,61 @@ fn morph_pairs_by_name_text_and_geometry() {
assert!(morph_pairs(&Slide::default(), &b).is_empty());
}
+#[test]
+fn morph_pairs_leaves_unrelated_text_boxes_alone() {
+ // A text box that leaves and an unrelated one that arrives fade in place (#119).
+ let p = deckcraft_model::defaults::new_presentation(None);
+ let mut a = Slide::default();
+ let mut b = Slide::default();
+ let tb = |id, name, x, text| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), text: Some(TextBody::from_text(text)), ..shape(id, name) };
+ a.shapes = vec![tb(1, "TextBox 1", 0.0, "Gone")];
+ b.shapes = vec![tb(2, "TextBox 2", 400.0, "New")];
+ assert!(morph_pairs(&a, &b).is_empty());
+ let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
+ let xs: Vec<(&str, f64)> = f.shapes.iter().map(|(s, _)| (s.name.as_str(), s.xfrm.map_or(f64::NAN, |x| x.x))).collect();
+ assert_eq!(xs, [("TextBox 1", 0.0), ("TextBox 2", 400.0)]);
+ // Empty shapes of one kind and geometry still pair, and a named text box keeps its partner.
+ a.shapes.push(shape(3, "Rect A"));
+ b.shapes.push(shape(4, "Rect B"));
+ b.shapes[0].name = "TextBox 1".into();
+ assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2)), (ShapeId(3), ShapeId(4))]);
+}
+
+#[test]
+fn morph_crop_moves_the_image_linearly() {
+ let bx = |x, w| Xfrm::new(x, 0.0, w, 100.0);
+ // Same box, the crop pans from the left half to the right half of a 200-wide image.
+ let c = morph_crop([0.0, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.5, 0.0, 0.0, 0.0], bx(0.0, 100.0), bx(0.0, 100.0), 0.5);
+ assert!(c.iter().zip([0.25, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}");
+ // The box shrinks over an image that stays put: only the right crop grows.
+ let c = morph_crop([0.0; 4], bx(0.0, 100.0), [0.0, 0.0, 0.5, 0.0], bx(0.0, 50.0), bx(0.0, 75.0), 0.5);
+ assert!(c.iter().zip([0.0, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}");
+ // Nothing visible on one side: the crops blend.
+ let c = morph_crop([0.5, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.0; 4], bx(0.0, 100.0), bx(0.0, 100.0), 0.5);
+ assert!(c.iter().all(|v| v.is_finite()) && (c[0] - 0.25).abs() < 1e-9, "{c:?}");
+}
+
+#[test]
+fn morph_frame_morphs_a_picture_crop() {
+ // A picture recropped on the next slide stays one picture whose crop morphs (#118).
+ let p = deckcraft_model::defaults::new_presentation(None);
+ let pic = |id, crop| Shape {
+ kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, ..Default::default() } },
+ ..shape(id, "Picture 1")
+ };
+ let mut a = Slide::default();
+ let mut b = Slide::default();
+ a.shapes = vec![pic(1, [0.0, 0.0, 0.5, 0.0])];
+ b.shapes = vec![pic(2, [0.5, 0.0, 0.0, 0.0])];
+ let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
+ assert_eq!(f.shapes.len(), 1);
+ assert!(f.opacity.is_empty());
+ let deckcraft_model::ShapeKind::Picture { fill } = &f.shapes[0].0.kind else { panic!("not a picture") };
+ let want = 0.5 * f.mix;
+ assert!((fill.crop[0] - want).abs() < 1e-9 && (fill.crop[2] - (0.5 - want)).abs() < 1e-9, "{:?}", fill.crop);
+ assert_eq!(morph_frame(&p, &a, &b, 1.0, &|_| false).shapes[0].0.kind, b.shapes[0].kind);
+}
+
#[test]
fn morph_xfrm_interpolates() {
let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) };
@@ -702,6 +757,37 @@ fn show_custom_show_and_range() {
assert_eq!(show_order(&p), vec![0, 1, 2, 3, 4]);
}
+#[test]
+fn playing_a_custom_show_stays_on_its_slides() {
+ use deckcraft_model::text::Action;
+ let mut p = pres(5);
+ p.custom_shows = vec![CustomShow { name: "Ends".into(), slides: vec![SlideId(1000), SlideId(1004)] }];
+ assert!(p.show.custom_show.is_none());
+ let mut s = ShowState::play_custom(&p, "Ends").expect("the show exists");
+ assert_eq!(s.custom_name(), Some("Ends"));
+ assert_eq!(s.slide, 0);
+ assert_eq!(s.peek_next(&p), Some(4));
+ assert_eq!(s.next(&p), ShowAction::Slide(4));
+ assert_eq!(s.next(&p), ShowAction::End);
+ assert_eq!(s.prev(&p), ShowAction::Slide(4));
+ assert_eq!(s.prev(&p), ShowAction::Slide(0));
+ assert!(ShowState::play_custom(&p, "Missing").is_none());
+ assert!(p.show.custom_show.is_none(), "one-shot playback does not change Set Up");
+
+ Arc::make_mut(&mut p.slides[4]).hidden = true;
+ let only = ShowState::play_custom(&p, "Ends").expect("the visible slide remains");
+ assert_eq!(only.first_index(&p), Some(0));
+ assert_eq!(only.last_index(&p), Some(0));
+
+ let mut s = ShowState::new(&p);
+ p.slides[4] = Arc::new(Slide { id: SlideId(1004), ..Default::default() });
+ assert_eq!(s.follow(&p, &Action::CustomShow { name: "Ends".into(), returns: true }, None), LinkJump::Show(ShowAction::Slide(0)));
+ assert_eq!(s.custom_name(), Some("Ends"));
+ assert_eq!(s.next(&p), ShowAction::Slide(4));
+ assert_eq!(s.follow(&p, &Action::CustomShow { name: "Missing".into(), returns: true }, None), LinkJump::None);
+ assert_eq!(s.slide, 4, "an unknown custom show leaves the current slide");
+}
+
#[test]
fn show_steps_through_builds() {
let mut p = pres(2);
@@ -789,3 +875,156 @@ fn show_follows_links() {
assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None);
assert_eq!(s.slide, 3);
}
+
+/// #420: late in the Honeycomb every cell has settled, so the incoming hexagons must cover the
+/// whole slide — the old row count stopped at ~75% of a 16:9 slide and left the bottom black.
+#[test]
+fn honeycomb_cells_reach_the_bottom_before_the_slide_ends() {
+ let sign = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| (a[0] - c[0]) * (b[1] - c[1]) - (b[0] - c[0]) * (a[1] - c[1]);
+ let inside = |p: [f64; 2], t: [[f64; 2]; 3]| {
+ // The dark seams between cells are the effect: at t=0.99 they are ~0.012% of the width
+ // open, so a 1% pad forgives them and still fails genuinely uncovered ground.
+ let c = [(t[0][0] + t[1][0] + t[2][0]) / 3.0, (t[0][1] + t[1][1] + t[2][1]) / 3.0];
+ let t = t.map(|v| [c[0] + (v[0] - c[0]) * 1.01, c[1] + (v[1] - c[1]) * 1.01]);
+ let (d1, d2, d3) = (sign(p, t[0], t[1]), sign(p, t[1], t[2]), sign(p, t[2], t[0]));
+ !((d1 < 0.0 || d2 < 0.0 || d3 < 0.0) && (d1 > 0.0 || d2 > 0.0 || d3 > 0.0))
+ };
+ for aspect in [16.0 / 9.0, 3.0 / 2.0, 4.0 / 3.0, 11.0 / 8.5, 1.0] {
+ // t = 0.99: every cell is settled and the seams are nearly closed.
+ let ls = transition_frame("honeycomb", "", 0.99, aspect);
+ let tris: Vec<[[f64; 2]; 3]> =
+ ls.iter().filter(|l| l.source == Source::New && l.alpha >= 1.0).map(|l| [l.quad[0], l.quad[1], l.quad[2]]).collect();
+ assert!(!tris.is_empty());
+ let mut sy = 0.0;
+ while sy <= 1.0 {
+ for sx in [0.0, 0.2, 0.4, 0.6, 0.8, 0.99, 1.0] {
+ let p = [sx, sy];
+ assert!(tris.iter().any(|t| inside(p, *t)), "honeycomb aspect {aspect} t=0.99: {sx},{sy} is uncovered (no settled cell reaches it)");
+ }
+ sy += 0.02;
+ }
+ }
+}
+
+/// #147: exits PowerPoint plays downward leave downward here too.
+#[test]
+fn sink_down_and_peek_out_to_bottom_move_down() {
+ for (effect, option) in [("sinkDown", ""), ("peekOut", "b"), ("floatOut", "d")] {
+ let mut a = anim(1, AnimClass::Exit, effect, AnimStart::OnClick);
+ a.option = option.into();
+ let tl = Timeline::new(&slide_with(vec![a]), W, H);
+ let s = tl.state(ShapeId(1), None, 0, 0.5);
+ assert!(s.offset_y > 0.0, "{effect} {option}: {s:?}");
+ }
+}
+
+#[test]
+fn trigger_sequences_keep_first_appearance_order_when_interleaved() {
+ let mut anims = vec![];
+ for (i, tr) in [7u32, 5, 7, 0, 9, 5].iter().enumerate() {
+ let mut a = anim(1 + (i as u32 % 4), AnimClass::Entrance, "fade", AnimStart::OnClick);
+ a.trigger = (*tr != 0).then_some(ShapeId(*tr));
+ anims.push(a);
+ }
+ let tl = Timeline::new(&slide_with(anims), W, H);
+ assert_eq!(tl.trigger_shapes(), vec![ShapeId(7), ShapeId(5), ShapeId(9)]);
+ assert_eq!(tl.trigger_steps(ShapeId(7)), 2);
+ assert_eq!(tl.trigger_steps(ShapeId(5)), 2);
+ assert_eq!(tl.trigger_steps(ShapeId(9)), 1);
+ assert_eq!(tl.steps(), 1);
+}
+
+#[test]
+fn many_distinct_triggers_past_the_effect_cap_build_in_order() {
+ // One sequence per animation: grouping used to rescan every animation per trigger
+ // (~1.2e10 steps here), now it is one pass, and nothing past the cap is looked at.
+ let n = 110_000u32;
+ let anims: Vec = (0..n)
+ .map(|i| {
+ let mut a = anim(1, AnimClass::Entrance, "appear", AnimStart::OnClick);
+ a.trigger = Some(ShapeId(1000 + i));
+ a
+ })
+ .collect();
+ let tl = Timeline::new(&slide_with(anims), W, H);
+ assert_eq!(tl.effects().len(), 100_000);
+ let shapes = tl.trigger_shapes();
+ assert_eq!(shapes.len(), 100_000);
+ assert!(shapes.iter().enumerate().all(|(i, s)| *s == ShapeId(1000 + i as u32)));
+}
+
+/// #316: the same text in two shapes that differ otherwise (a title slide's big centred title,
+/// a content slide's title) morphs word by word with the default Objects option, instead of
+/// the old box cross-fading into the new one.
+#[test]
+fn same_text_morphs_even_by_objects() {
+ let p = deckcraft_model::defaults::new_presentation(None);
+ let titled = |id, x, size: f64, text: &str| {
+ let mut body = TextBody::from_text(text);
+ for para in &mut body.paragraphs {
+ for r in &mut para.runs {
+ r.props.size = Some(size);
+ }
+ }
+ Shape { xfrm: Some(Xfrm::new(x, 100.0, 600.0, 120.0)), text: Some(body), ..shape(id, "Title 1") }
+ };
+ let mut a = Slide::default();
+ let mut b = Slide::default();
+ a.shapes = vec![titled(1, 100.0, 60.0, "TEST")];
+ b.shapes = vec![titled(2, 40.0, 44.0, "TEST")];
+ let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
+ assert_eq!(f.text.len(), 1, "the shared text morphs");
+ assert!(!f.text[0].chars);
+ // Different texts still cross-fade with the Objects option.
+ b.shapes = vec![titled(2, 40.0, 44.0, "Other")];
+ assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty());
+}
+
+#[test]
+fn media_in_the_click_sequence_cues_and_does_not_hold_the_click() {
+ // #116: a video set to play in the click sequence starts with its step, and the step doesn't
+ // last as long as the video (the next click goes on while it plays).
+ let play = Animation { duration_ms: 5000, ..anim(1, AnimClass::Media, "play", AnimStart::OnClick) };
+ let tl = Timeline::new(&slide_with(vec![play.clone(), anim(2, AnimClass::Exit, "fadeOut", AnimStart::OnClick)]), W, H);
+ assert_eq!(tl.steps(), 2);
+ assert_eq!(tl.step_duration(0), 0.0);
+ let cues = tl.media_cues(0, f64::NEG_INFINITY, 0.0);
+ assert_eq!(cues.iter().map(|e| (e.shape, e.anim)).collect::>(), [(ShapeId(1), 0)]);
+ assert!(tl.media_cues(0, 0.0, f64::INFINITY).is_empty(), "set off once");
+ assert!(tl.media_cues(1, f64::NEG_INFINITY, f64::INFINITY).is_empty(), "only media effects");
+ // An After Previous effect still waits for the video.
+ let tl = Timeline::new(&slide_with(vec![play, anim(2, AnimClass::Entrance, "fade", AnimStart::AfterPrevious)]), W, H);
+ assert!((tl.step_duration(0) - 6.0).abs() < 1e-9, "{}", tl.step_duration(0));
+}
+
+/// Zoom with Return to Zoom: after the zoomed slides the show goes back to the zoom slide, fully
+/// built; without it, or after jumping elsewhere, the show carries on in order.
+#[test]
+fn show_returns_to_the_zoom_slide() {
+ use deckcraft_model::text::{Action, Hyperlink};
+ let p = pres(6);
+ let link = |to: u32, last: Option| Hyperlink {
+ action: Action::Slide { slide: SlideId(1000 + to) },
+ tooltip: String::new(),
+ highlight_click: false,
+ zoom_return: last.map(|l| SlideId(1000 + l)),
+ sound: None,
+ stop_sound: false,
+ };
+ let mut s = ShowState::new(&p);
+ // A section zoom on slide 0 for slides 2–3.
+ assert_eq!(s.follow_link(&p, &link(2, Some(3)), None), LinkJump::Show(ShowAction::Slide(2)));
+ assert_eq!(s.next(&p), ShowAction::Slide(3));
+ assert_eq!(s.next(&p), ShowAction::Slide(0), "back to the zoom");
+ assert_eq!(s.step, ShowState::steps(&p, 0));
+ assert_eq!(s.next(&p), ShowAction::Slide(1), "then on in order");
+ // Without Return to Zoom the show carries on.
+ let mut s = ShowState::new(&p);
+ s.follow_link(&p, &link(4, None), None);
+ assert_eq!(s.next(&p), ShowAction::Slide(5));
+ // Jumping elsewhere (See All Slides, a number) forgets the zoom.
+ let mut s = ShowState::new(&p);
+ s.follow_link(&p, &link(2, Some(2)), None);
+ s.goto(&p, 4);
+ assert_eq!(s.next(&p), ShowAction::Slide(5));
+}
diff --git a/crates/anim/src/tests_interact.rs b/crates/anim/src/tests_interact.rs
new file mode 100644
index 00000000..5f12dda5
--- /dev/null
+++ b/crates/anim/src/tests_interact.rs
@@ -0,0 +1,364 @@
+//! Clicks, triggered animations and what the show remembers about visited slides.
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use std::sync::Arc;
+
+use deckcraft_model::text::Action;
+use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm};
+
+use crate::*;
+
+fn shape(id: u32) -> Shape {
+ Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() }
+}
+
+/// A triggered exit (`Disappear` when `ms` is 0, else a fade out) of `target`, started by clicking `trigger`.
+fn exit_on_click(trigger: u32, target: u32, ms: u32) -> Animation {
+ let effect = if ms == 0 { "disappear" } else { "fadeOut" };
+ Animation {
+ shape: ShapeId(target),
+ class: AnimClass::Exit,
+ effect: effect.into(),
+ start: AnimStart::OnClick,
+ duration_ms: ms,
+ trigger: Some(ShapeId(trigger)),
+ ..Default::default()
+ }
+}
+
+fn slide(id: u32, shapes: Vec, animations: Vec, advance_on_click: bool) -> Arc {
+ let transition = (!advance_on_click).then(|| Transition { advance_on_click: false, ..Default::default() });
+ Arc::new(Slide { id: SlideId(id), shapes, animations, transition, ..Default::default() })
+}
+
+fn to_slide(n: u32) -> Option {
+ Some(Action::Slide { slide: SlideId(n) })
+}
+
+fn hit(id: u32, link: Option) -> HitShape {
+ HitShape { id: ShapeId(id), parent: None, is_group: false, link, under_pointer: true }
+}
+
+/// Test driver: a show plus the click helpers the slide show window uses.
+struct Run {
+ pres: Presentation,
+ show: ShowState,
+ /// Hit shapes of each slide, back to front.
+ hits: Vec>,
+ now: f64,
+}
+
+impl Run {
+ fn new(pres: Presentation, hits: Vec>) -> Run {
+ let show = ShowState::new(&pres);
+ Run { pres, show, hits, now: 0.0 }
+ }
+
+ fn tl(&self) -> Timeline {
+ ShowState::timeline(&self.pres, self.show.slide)
+ }
+
+ fn visible(&self, id: ShapeId) -> bool {
+ let st = self.show.anim_state(&self.tl(), id, 0.0);
+ st.visible && st.opacity > 0.0
+ }
+
+ fn opacity(&self, id: u32) -> f64 {
+ self.show.anim_state(&self.tl(), ShapeId(id), 0.0).opacity
+ }
+
+ fn click(&mut self) -> Click {
+ let (tl, hits) = (self.tl(), self.hits[self.show.slide].clone());
+ let vis: Vec<(ShapeId, bool)> = hits.iter().map(|h| (h.id, self.visible(h.id))).collect();
+ let visible = move |id: ShapeId| vis.iter().find(|v| v.0 == id).is_none_or(|v| v.1);
+ self.show.click(&self.pres, &tl, &hits, &visible, self.now, None)
+ }
+
+ fn target(&self) -> Option {
+ let (tl, hits) = (self.tl(), &self.hits[self.show.slide]);
+ self.show.target(&self.pres, &tl, hits, &|id| self.visible(id))
+ }
+
+ /// Let `secs` pass on the shown slide, in small frames.
+ fn wait(&mut self, secs: f64) {
+ let end = self.now + secs;
+ while self.now < end - 1e-9 {
+ self.now = (self.now + 0.05).min(end);
+ let tl = self.tl();
+ self.show.tick(&tl, self.now);
+ }
+ }
+}
+
+/// The test deck: slide 1 has a text box (3), a linked button that also disappears (2) and a
+/// picture on top of both that disappears (4); slide 2 has a button back to slide 1.
+fn two_slides(advance_on_click: bool) -> Run {
+ let s1 = slide(1000, vec![shape(3), shape(2), shape(4)], vec![exit_on_click(4, 4, 0), exit_on_click(2, 2, 0)], advance_on_click);
+ let s2 = slide(1001, vec![shape(1)], vec![], advance_on_click);
+ let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
+ Run::new(pres, vec![vec![hit(3, None), hit(2, to_slide(1001)), hit(4, None)], vec![hit(1, to_slide(1000))]])
+}
+
+#[test]
+fn hidden_shapes_stay_hidden_when_the_show_comes_back() {
+ let mut r = two_slides(false);
+ assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(4)), "the picture is on top");
+ // Click the picture: it disappears; the button beneath it is now the target.
+ assert_eq!(r.click(), Click::Animated);
+ assert!(!r.visible(ShapeId(4)));
+ assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(2)));
+ // Click the button: its animation starts and the link is followed.
+ assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
+ assert_eq!(r.show.slide, 1);
+ // Slide 2: the button goes back.
+ assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
+ assert_eq!(r.show.slide, 0);
+ // Both are still gone, and nothing on the slide can be clicked any more.
+ assert!(!r.visible(ShapeId(4)) && !r.visible(ShapeId(2)) && r.visible(ShapeId(3)));
+ assert_eq!(r.target(), None);
+ // "Advance on click" is off: a click anywhere does nothing, however often.
+ for _ in 0..3 {
+ assert_eq!(r.click(), Click::None);
+ }
+ assert_eq!(r.show.slide, 0);
+}
+
+#[test]
+fn background_click_advances_when_the_slide_allows_it() {
+ let mut r = two_slides(true);
+ r.click(); // the picture disappears
+ r.click(); // the button disappears and links to slide 2
+ r.click(); // the button there links back
+ assert_eq!(r.show.slide, 0);
+ assert_eq!(r.target(), None);
+ assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
+}
+
+#[test]
+fn advance_on_click_off_still_plays_animation_steps() {
+ let main = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() };
+ let s1 = slide(1000, vec![shape(1)], vec![main], false);
+ let s2 = slide(1001, vec![], vec![], false);
+ let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
+ let mut r = Run::new(pres, vec![vec![hit(1, None)], vec![]]);
+ // The first click plays the step, the next finishes it; then the slide stays.
+ assert!(matches!(r.click(), Click::Show(ShowAction::PlayStep(0))));
+ assert!(matches!(r.click(), Click::Show(ShowAction::FinishStep(0))));
+ assert_eq!(r.click(), Click::None);
+ assert_eq!(r.show.slide, 0);
+ // The arrow keys still move on.
+ assert_eq!(r.show.next(&r.pres), ShowAction::Slide(1));
+}
+
+#[test]
+fn a_shape_without_an_action_blocks_the_one_beneath_it() {
+ let s = slide(1000, vec![shape(1), shape(2)], vec![], true);
+ let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
+ let mut r = Run::new(pres, vec![vec![hit(1, to_slide(1001)), hit(2, None)], vec![]]);
+ assert_eq!(r.target(), None, "the plain shape on top blocks the button");
+ // So the click is a background click, not the button's link.
+ assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
+ // Reversed, the button is on top and is the target.
+ let s = slide(1000, vec![shape(2), shape(1)], vec![], true);
+ let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
+ let r = Run::new(pres, vec![vec![hit(2, None), hit(1, to_slide(1001))], vec![]]);
+ assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
+}
+
+#[test]
+fn groups_answer_for_their_children() {
+ let s = slide(1000, vec![shape(1)], vec![], true);
+ let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
+ let group = |link| HitShape { id: ShapeId(10), parent: None, is_group: true, link, under_pointer: true };
+ let child = HitShape { id: ShapeId(11), parent: Some(1), is_group: false, link: None, under_pointer: true };
+ // A child of a group with an action acts as the group.
+ let r = Run::new(pres.clone(), vec![vec![hit(5, None), group(to_slide(1001)), child.clone()], vec![]]);
+ assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(10)));
+ // Empty space inside the box of a group without an action blocks nothing.
+ let behind = hit(5, to_slide(1001));
+ let r = Run::new(pres, vec![vec![behind, group(None)], vec![]]);
+ assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(5)));
+}
+
+#[test]
+fn pointer_outside_a_shape_is_not_on_it() {
+ let s = slide(1000, vec![shape(1)], vec![], true);
+ let pres = Presentation { slides: vec![s], ..Default::default() };
+ let mut h = hit(1, to_slide(1000));
+ h.under_pointer = false;
+ let r = Run::new(pres, vec![vec![h]]);
+ assert_eq!(r.target(), None);
+}
+
+#[test]
+fn each_click_plays_one_step_of_a_trigger_and_a_click_completes_a_playing_step() {
+ // Clicking shape 1 fades shape 2 out over 5 s, then shape 3 over 5 s.
+ let s = slide(1000, vec![shape(1), shape(2), shape(3)], vec![exit_on_click(1, 2, 5000), exit_on_click(1, 3, 5000)], true);
+ let pres = Presentation { slides: vec![s], ..Default::default() };
+ let mut r = Run::new(pres, vec![vec![hit(2, None), hit(3, None), hit(1, None)]]);
+ assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
+ assert_eq!(r.click(), Click::Animated);
+ r.wait(2.0);
+ assert!(r.show.trigger_playing());
+ assert!(r.opacity(2) < 1.0 && r.opacity(2) > 0.0, "part way through the fade");
+ assert!(r.visible(ShapeId(3)));
+ // A click on the shape while its step plays completes the step at once...
+ assert_eq!(r.click(), Click::Animated);
+ assert!(!r.visible(ShapeId(2)) && !r.show.trigger_playing());
+ // ...and the next click plays the next step.
+ assert_eq!(r.click(), Click::Animated);
+ r.wait(5.2);
+ assert!(!r.visible(ShapeId(3)));
+ // All steps have played, and the shape still answers: its sequence starts over.
+ assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
+ assert_eq!(r.click(), Click::Animated);
+ assert!(r.visible(ShapeId(2)) && r.visible(ShapeId(3)), "the first lap's effects are undone");
+ r.wait(5.2);
+ assert!(!r.visible(ShapeId(2)) && r.visible(ShapeId(3)));
+}
+
+#[test]
+fn a_ring_of_triggers_goes_round_and_round() {
+ // Soccer's score: clicking shape k hides it and shows shape k+1; the last shows the first again.
+ let ring = 4u32;
+ let anims: Vec = (1..=ring)
+ .flat_map(|k| {
+ let next = k % ring + 1;
+ let show = Animation {
+ shape: ShapeId(next),
+ class: AnimClass::Entrance,
+ effect: "appear".into(),
+ start: AnimStart::WithPrevious,
+ duration_ms: 0,
+ trigger: Some(ShapeId(k)),
+ ..Default::default()
+ };
+ [exit_on_click(k, k, 0), show]
+ })
+ .collect();
+ let shapes: Vec = (1..=ring).map(shape).collect();
+ let s = slide(1000, shapes, anims, true);
+ let pres = Presentation { slides: vec![s], ..Default::default() };
+ let hits: Vec = (1..=ring).map(|k| hit(k, None)).collect();
+ let mut r = Run::new(pres, vec![hits]);
+ let showing = |r: &Run| -> Vec { (1..=ring).filter(|k| r.visible(ShapeId(*k))).collect() };
+ assert_eq!(showing(&r), vec![1]);
+ for lap in 0..3 {
+ for k in 1..=ring {
+ assert_eq!(showing(&r), vec![k], "lap {lap}, before click {k}");
+ assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(k)));
+ assert_eq!(r.click(), Click::Animated);
+ }
+ }
+ assert_eq!(showing(&r), vec![1]);
+}
+
+#[test]
+fn a_link_is_followed_at_once_and_the_animation_goes_on_where_it_stopped() {
+ // Shape 1 fades shape 4 over 5 s; shape 2 links to slide 2.
+ let s1 = slide(1000, vec![shape(1), shape(2), shape(4)], vec![exit_on_click(1, 4, 5000)], false);
+ let s2 = slide(1001, vec![shape(5)], vec![], false);
+ let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
+ let mut r = Run::new(pres, vec![vec![hit(4, None), hit(2, to_slide(1001)), hit(1, None)], vec![hit(5, to_slide(1000))]]);
+ // Click the animated shape, wait 2 s, click the link.
+ r.click();
+ r.wait(2.0);
+ assert_eq!(r.show.trigger_plays()[0].t.round(), 2.0);
+ // The second shape is not over the first one, so click the link directly.
+ let tl = r.tl();
+ let hits = vec![hit(2, to_slide(1001))];
+ assert_eq!(r.show.click(&r.pres, &tl, &hits, &|_| true, r.now, None), Click::Show(ShowAction::Slide(1)));
+ // However long the show stays away, the animation does not run.
+ r.wait(60.0);
+ assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
+ // Back on the slide it has 3 s left.
+ assert!((r.show.trigger_plays()[0].t - 2.0).abs() < 0.1);
+ assert!(r.show.trigger_playing());
+ r.wait(2.5);
+ assert!(r.show.trigger_playing() && r.opacity(4) > 0.0);
+ r.wait(1.0);
+ assert!(!r.show.trigger_playing() && !r.visible(ShapeId(4)));
+}
+
+#[test]
+fn a_shape_with_a_link_and_an_animation_follows_the_link_without_waiting() {
+ let s1 = slide(1000, vec![shape(2)], vec![exit_on_click(2, 2, 5000)], false);
+ let s2 = slide(1001, vec![shape(5)], vec![], false);
+ let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
+ let mut r = Run::new(pres, vec![vec![hit(2, to_slide(1001))], vec![hit(5, to_slide(1000))]]);
+ assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
+ r.wait(30.0);
+ assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
+ // The whole animation is still ahead.
+ assert!(r.visible(ShapeId(2)) && r.opacity(2) > 0.95);
+ assert!(r.show.trigger_playing());
+ r.wait(5.5);
+ assert!(!r.visible(ShapeId(2)));
+}
+
+#[test]
+fn a_slide_comes_back_the_way_it_was_left() {
+ let steps: Vec = (1..=3)
+ .map(|i| Animation { shape: ShapeId(i), class: AnimClass::Entrance, effect: "appear".into(), duration_ms: 0, ..Default::default() })
+ .collect();
+ let s1 = slide(1000, vec![shape(1), shape(2), shape(3)], steps, true);
+ let s2 = slide(1001, vec![], vec![], true);
+ let s3 = slide(1002, vec![], vec![], true);
+ let pres = Presentation { slides: vec![s1, s2, s3], ..Default::default() };
+ let mut s = ShowState::new(&pres);
+ s.next(&pres);
+ s.step_done(&pres);
+ assert_eq!(s.step, 1);
+ // A link away and back: still after step 1.
+ assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1002) }, None), LinkJump::Show(ShowAction::Slide(2)));
+ assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1000) }, None), LinkJump::Show(ShowAction::Slide(0)));
+ assert_eq!((s.step, s.playing), (1, false));
+ // So does the back arrow.
+ s.goto(&pres, 1);
+ assert_eq!(s.prev(&pres), ShowAction::Slide(0));
+ assert_eq!(s.step, 1);
+ // A slide the show has not been to is entered at its start; going back to one shows it fully
+ // built.
+ let mut s = ShowState::new(&pres);
+ s.goto(&pres, 2);
+ assert_eq!(s.prev(&pres), ShowAction::Slide(1));
+ assert_eq!(s.step, ShowState::steps(&pres, 1));
+}
+
+#[test]
+fn a_playing_main_step_is_finished_when_the_show_leaves() {
+ let a = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() };
+ let s1 = slide(1000, vec![shape(1)], vec![a], true);
+ let s2 = slide(1001, vec![], vec![], true);
+ let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
+ let mut s = ShowState::new(&pres);
+ assert_eq!(s.next(&pres), ShowAction::PlayStep(0));
+ s.goto(&pres, 1);
+ s.goto(&pres, 0);
+ assert_eq!((s.step, s.playing), (1, false));
+}
+
+#[test]
+fn show_without_animation_ignores_triggers() {
+ let s = slide(1000, vec![shape(1), shape(2)], vec![exit_on_click(1, 2, 0)], true);
+ let mut pres = Presentation { slides: vec![s], ..Default::default() };
+ pres.show.without_animation = true;
+ let r = Run::new(pres, vec![vec![hit(2, None), hit(1, None)]]);
+ assert_eq!(r.target(), None);
+}
+
+#[test]
+fn clocks_and_pointers_with_hostile_numbers_are_harmless() {
+ let mut r = two_slides(true);
+ let tl = r.tl();
+ for now in [f64::NAN, f64::INFINITY, -1.0e300, 1.0e300, 0.0] {
+ r.show.tick(&tl, now);
+ let _ = r.show.click(&r.pres, &tl, &[], &|_| true, now, None);
+ }
+ // A parent index that points nowhere, or in a circle, is not followed forever.
+ let bad = vec![
+ HitShape { id: ShapeId(1), parent: Some(7), is_group: true, link: None, under_pointer: true },
+ HitShape { id: ShapeId(2), parent: Some(2), is_group: false, link: None, under_pointer: true },
+ ];
+ assert_eq!(r.show.target(&r.pres, &tl, &bad, &|_| true), None);
+}
diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs
index e73acfd9..98e91f96 100644
--- a/crates/anim/src/timeline.rs
+++ b/crates/anim/src/timeline.rs
@@ -1,5 +1,7 @@
//! A slide's animation sequence: click steps, effect start times and per-shape state over time.
+use std::collections::{HashMap, HashSet};
+
use deckcraft_model::anim::TextBuild;
use deckcraft_model::style::Fill;
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm};
@@ -57,6 +59,16 @@ pub struct TriggerPlay {
pub t: f64,
}
+/// When a step of a triggered sequence was started, counted in started steps on the slide. Effects
+/// of different triggers apply in this order, so that a shape hidden by one trigger and shown again
+/// by another ends up as the later of the two left it.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct StepStamp {
+ pub trigger: ShapeId,
+ pub step: usize,
+ pub at: u64,
+}
+
#[derive(Clone, Debug, Default)]
struct StepInfo {
auto: bool,
@@ -151,25 +163,31 @@ impl Timeline {
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);
- }
+ // Group the animations by sequence in one pass, sequences in order of first appearance
+ // (main first), so many distinct triggers cost linear work, not a scan per trigger.
+ let mut seqs: Vec<(Sequence, Vec)> = vec![(Sequence::Main, Vec::new())];
+ let mut seq_index: HashMap = HashMap::from([(Sequence::Main, 0)]);
+ for (ai, a) in slide.animations.iter().enumerate() {
+ let s = a.trigger.map(Sequence::Trigger).unwrap_or(Sequence::Main);
+ let i = *seq_index.entry(s).or_insert_with(|| {
+ seqs.push((s, Vec::new()));
+ seqs.len() - 1
+ });
+ if let Some((_, members)) = seqs.get_mut(i) {
+ members.push(ai);
}
}
- for seq in seqs {
+ // Paragraph groups already recorded (the list itself keeps their order).
+ let mut grouped: HashSet<(ShapeId, usize, usize)> = HashSet::new();
+ for (seq, members) in seqs {
+ if tl.effects.len() >= MAX_EFFECTS {
+ break;
+ }
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;
- }
+ for ai in members {
+ let Some(a) = slide.animations.get(ai) else { continue };
if tl.effects.len() >= MAX_EFFECTS {
break;
}
@@ -188,7 +206,7 @@ impl Timeline {
owner = Some(i);
v.push(Some(i));
} else if let Some(o) = owner
- && !tl.groups.contains(&(a.shape, i, o))
+ && grouped.insert((a.shape, i, o))
{
tl.groups.push((a.shape, i, o));
}
@@ -249,7 +267,9 @@ impl Timeline {
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);
+ // Media plays on by itself once started: it holds back an After Previous
+ // effect (`max_end`), but not the next click (#116).
+ s.duration = s.duration.max(if a.class == AnimClass::Media { start } else { end });
}
tl.effects.push(Effect {
timing: EffectTiming {
@@ -308,6 +328,20 @@ impl Timeline {
self.main.get(i).map(|s| s.duration).unwrap_or(0.0)
}
+ /// Media effects (Play, Pause, Stop) of main step `step` that start after `from` and by `to`
+ /// seconds into it, in order: what a show running the step from `from` to `to` sets off.
+ pub fn media_cues(&self, step: usize, from: f64, to: f64) -> Vec {
+ let mut v: Vec = self
+ .effects
+ .iter()
+ .map(|e| &e.timing)
+ .filter(|e| e.class == AnimClass::Media && e.sequence == Sequence::Main && e.step == step && e.start > from && e.start <= to)
+ .cloned()
+ .collect();
+ v.sort_by(|a, b| a.start.total_cmp(&b.start));
+ v
+ }
+
/// Total duration of the main sequence played without pauses.
pub fn total_duration(&self) -> f64 {
self.main.iter().map(|s| s.duration).sum()
@@ -379,6 +413,21 @@ impl Timeline {
/// [`Timeline::state`] with the playheads of triggered sequences.
pub fn state_with(&self, shape: ShapeId, para: Option, step: usize, t: f64, triggers: &[TriggerPlay]) -> AnimState {
+ self.state_ordered(shape, para, step, t, triggers, &[])
+ }
+
+ /// [`Timeline::state_with`] applying the effects of triggered sequences in the order their steps
+ /// were started (`stamps`), after those of the main sequence. Without stamps they apply in
+ /// Animation Pane order.
+ pub fn state_ordered(
+ &self,
+ shape: ShapeId,
+ para: Option,
+ step: usize,
+ t: f64,
+ triggers: &[TriggerPlay],
+ stamps: &[StepStamp],
+ ) -> AnimState {
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
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));
@@ -391,7 +440,16 @@ impl Timeline {
{
st.visible = false;
}
- // Then play in time order: main sequence first, then triggered sequences.
+ // Then play in time order: main sequence first, then triggered sequences as they were clicked.
+ if !stamps.is_empty() {
+ let when = |e: &Effect| match e.timing.sequence {
+ Sequence::Main => 0,
+ Sequence::Trigger(tr) => {
+ stamps.iter().find(|s| s.trigger == tr && s.step == e.timing.step).map_or(u64::MAX, |s| s.at.saturating_add(1))
+ }
+ };
+ ordered.sort_by_key(|e| (when(e), e.timing.anim));
+ }
for e in &ordered {
let (pstep, pt) = match e.timing.sequence {
Sequence::Main => (step, t),
@@ -471,7 +529,8 @@ fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&deckc
EffectSpec {
class: a.class,
effect,
- option: a.option.clone(),
+ // Sink Down is Rise Up played backwards, leaving downward.
+ option: if a.effect == "sinkDown" { "d".into() } else { a.option.clone() },
accel: a.smooth_start,
decel: a.smooth_end,
bounce: a.bounce_end,
diff --git a/crates/anim/src/transition_fx.rs b/crates/anim/src/transition_fx.rs
index d5fb2e16..3bbc4a8d 100644
--- a/crates/anim/src/transition_fx.rs
+++ b/crates/anim/src/transition_fx.rs
@@ -794,7 +794,11 @@ fn honeycomb(t: f64, a: f64) -> Vec {
let cols = 11usize;
let w = 1.0 / cols as f64;
let rh = w * a * 0.5;
- let rows = (1.0 / (rh * 1.5)).ceil() as usize + 2;
+ // Rows sit 0.866*rh apart; a settled hex reaches only 0.577*rh from its centre at the side
+ // vertices, and the flat bottom zone 0.289*rh. Count the rows whose FLAT zone still covers
+ // the slide's bottom: counting the vertex reach instead left V-notches of black above the
+ // bottom edge on about a third of the aspect ratios (#420).
+ let rows = ((1.0 / (rh * 0.866) - 1.0 / 3.0).ceil().max(1.0)) as usize + 1;
let mut v = vec![Layer::full(Black, 1.0)];
let seam = ss(win(t, 0.0, 0.12)) * (1.0 - ss(win(t, 0.85, 1.0)));
let hex = |cx: f64, cy: f64, s: f64| -> Vec<[f64; 2]> {
diff --git a/crates/av1/src/decoder.rs b/crates/av1/src/decoder.rs
index 66c96698..06de1589 100644
--- a/crates/av1/src/decoder.rs
+++ b/crates/av1/src/decoder.rs
@@ -206,12 +206,35 @@ pub struct Decoder {
/// Frames submitted to the workers and not known to be finished (oldest first).
in_flight: VecDeque>,
max_in_flight: usize,
+ /// Bytes the picture store may take ([`deckcraft_bitstream::budget`]).
+ memory_budget: u64,
/// Shown pictures not yet returned, in output order.
out: VecDeque