Compare commits

...
Author SHA1 Message Date
FredApps de9e79a445 docs: preserve local guidance in house-rules rollout 2026-11-06 12:36:46 +01:00
FredApps d7343553b3 Copy the shared house rules into AGENTS.md.
The blocks come from craft-contrib at dde09ea31e2e457f5b4653a6e76f7cdf55944bdf. CLAUDE.md imports AGENTS.md. The house-rules workflow checks the copy.
2026-11-06 11:50:01 +01:00
Luis 9a95c3a639 Release: refresh contributor credits for each release (#1889) (#1890)
About > Contributors still showed the snapshot taken for v0.4.0, because
nothing regenerated contributors/contributors.json before a release.
Opt in to the shared refresh from craft-common-actions: a read-only
credits job regenerates the snapshot for the release commit, and the
shared release workflow installs it before every source build. The
snapshot is not committed back.
2026-11-05 17:23:44 +01:00
FredApps daeedd5d19 Merge pull request #1895 from helenkwok/web/host-crate
deckcraft-web: host messages through craft-web-host, and page host mode
2026-11-05 14:11:06 +01:00
FredAppsandCursor a2c1633fe3 fix(web): refuse a host file of exactly 2 GiB
wasm32 cannot allocate a Vec of 2 GiB (isize::MAX is one byte less), so
the open check that only rejected a larger file trapped on that boundary.
Refuse 2 GiB as well, and say so in the failure the host receives.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 13:59:10 +01:00
aiongg bfdcc5cdfa Apply review fixes
- packaging/web/README.md
- apps/deckcraft-web/src/host.rs
2026-11-05 11:55:08 +00:00
Michael Müllerandaiongg 73de9900a7 Show: Presenter View only with a second display (#1156)
* Show: Presenter View only with a second display

With one display the presenter window opened on the same screen as the show. Under Hyprland's
focus-follows-mouse, whichever window the pointer was over took full screen, and the hidden one
stalled every frame: the show lagged, dropped key presses or froze.

One display now shows only the slides, as in PowerPoint. The host counts displays through winit's
monitor list when a show starts; hosts that can't tell keep Presenter View.

* Show: count displays by MonitorHandle

Wayland lists one output twice under the same wl_output id, so comparing handles counts it once, and two separate displays with the same name and geometry still count as two.

* Show: docs for Presenter View with a second display

slideshow-parity and hardware-parity note that on Linux one display shows only the slides, with status lines and revision history; ROADMAP progress log. display_count collects into a BTreeSet (MonitorHandle's Ord matches its Eq).

* Show: docs state the one-display rule exactly

Presenter View stays off when winit lists exactly one display; if it lists none, the setting is kept. The host callback covers Unix other than macOS, so the docs say Linux and the BSDs.

* Apply review fixes

- docs/slideshow-parity.md

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-05 12:51:16 +01:00
aiongg b225bb16a1 Apply review fixes
- apps/deckcraft-web/src/host.rs
2026-11-05 11:49:17 +00:00
Helen Kwok 9299113268 deckcraft-web: speak the host messages through craft-web-host, and add page host mode
The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420). `data-host="page"` on the canvas now hosts DeckCraft in a page's own document, as in the other apps; the parent bridge keeps its behaviour.
2026-11-05 17:51:01 +08:00
FredApps 2b6d27d525 Merge pull request #1658 from SSchott/copy-the-fix
Fix Copying Images, Ctrl-C/Ctrl-V and within Deckcraft
2026-11-05 05:59:48 +01:00
FredApps 9998ee5e07 Merge pull request #1809 from jimprince/web-host-bridge
Web: open and save through a framing page (?host=parent)
2026-11-05 05:59:44 +01:00
FredApps 9e8aa81298 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:49:57 +01:00
FredApps 4f171e8f78 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:41:44 +01:00
FredApps d82aaddab4 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:24:59 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 070f12cc9c Keep Source Formatting resolves colours as the renderer does
Review follow-up on #1658 (CodeRabbit), both from storing the slide's
mapped scheme in 30ccc38e:

- Copying in Slide Master view used the current slide's colour map. Use
  `ctx_for`, so the theme is that of what is being edited.
- `ColorScheme` shares an entry between `dk1` and `tx1`, so a mapped scheme
  changed direct `dk1` colours too: a dark fill pasted white. `SourceTheme`
  keeps the theme's colours and the map apart, and `bake_theme` maps only
  `tx1`, `bg1`, `tx2` and `bg2`, as `Ctx::color` does.

The browser image test now checks that non-picture pastes keep their text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:23:35 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 46a15a4684 Merge branch 'main' into copy-the-fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:19:46 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 30ccc38e90 Keep Source Formatting uses the slide's colour map
Review follow-up on #1658 (CodeRabbit): `source_theme` stored the master's
scheme, so a slide or layout that overrides the colour map had its colours
fixed through the wrong map. Detaching made this reach a placeholder's
inherited colours: a title drawn white under an override pasted black.
Store the scheme the slide draws with. Paste as Picture reads the same
value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:02:22 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 d6cc51e07a Alt+drag detaches placeholders; Slide Master view pastes them as placeholders
Review follow-up on #1658:

- The drag duplicate cleared `ph` without detaching, so a title or body
  dragged off with Alt lost its size and bullets. Copy, Duplicate and the
  drag now share `detach_on_slide`.
- Detaching at copy time turned a slide placeholder pasted in Slide Master
  view into a regular shape. The clipboard keeps the originals, and a paste
  there takes them, so it stays a placeholder as before this PR. Keep
  Source Formatting bakes them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:43:33 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 077cd36e9f Merge branch 'main' into copy-the-fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:38:04 +01:00
Stephan Schott-Verdugo ba3d74dfb5 Merge branch 'main' into copy-the-fix 2026-11-03 09:10:31 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 1192e5ef30 Detached placeholders follow each property's style precedence
Review follow-up (CodeRabbit on #1658):

- Text levels are resolved before the copy's style changes. A master
  placeholder's font reference reached the copy's text (body font in an
  accent colour for a title) although the original ignores it: text only
  takes the shape's or the layout placeholder's font reference.
- Fill, line and effects come from where `resolve::fill`, `resolve::line`
  and `resolve::effects` take them: the first shape in the chain with an
  explicit value or a style. A layout's (or the slide's own) style no
  longer loses to an explicit fill, line or effect on the master. The
  copy gets one style whose references each come from the shape that
  supplied that property; unused references are neutral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:06:09 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 f006b5d8ce Ctrl+V pastes a picture copied in a browser, not its link
A browser's Copy Image puts the picture on the clipboard together with
text that only names it (the address and alt text, or Firefox's UTF-16
text/x-moz-url read as UTF-8) and an <img> HTML fragment. Ctrl+V only
looked for a picture when the text was empty, so it pasted a text box
with the link. When the text or HTML only names a picture, paste the
picture; with no picture on the clipboard the link still pastes as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:38:05 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 e1cfe8516e Paste and duplicate keep a placeholder's inherited look
A placeholder copied or duplicated on a slide became a regular shape by
clearing `ph`, which also dropped everything it inherited from its layout
and master: the title's 44 pt heading font, the body's bullets and indent
levels, autofit and the box. Without `ph` the text fell back to the
presentation's default style (18 pt, no bullets), on Ctrl+V and the
context menu alike.

`resolve::detach_placeholder` makes the inherited box, fill, line,
effects, body properties and every level's text style the shape's own
before `ph` is cleared. Theme references stay references, so the default
paste still follows the destination theme. Copy (so Cut and pasting into
another deck work after the source is gone) and Duplicate use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:29:43 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 cbb0c48445 Fix OS clipboard: enable eframe's clipboard feature dropped in the egui 0.37 migration
Since #1350 (v0.11.1) the desktop build listed eframe features without
`clipboard`, so egui-winit simulated a clipboard inside the app: Ctrl+V
never read text or pictures from other apps, and Ctrl+C of text (sent out
as HTML through arboard) never reached egui's clipboard, so Ctrl+V pasted
nothing or stale text. The context menu kept working because it only uses
the session clipboard.

Turn the feature back on. On native Wayland, egui-winit owns the clipboard
through winit's data device (text and PNG); leave the X11-only arboard
pasteboard (HTML, pictures) off there so copies are claimed on the Wayland
selection. Regression tests guard the feature list and the Wayland switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:19:06 +01:00
28 changed files with 967 additions and 192 deletions
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+8
View File
@@ -19,7 +19,14 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
@@ -30,6 +37,7 @@ jobs:
# manifest fails the release instead of silently skipping it.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+61 -10
View File
@@ -1,3 +1,56 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# AGENTS.md — rules for every agent and contributor
DeckCraft is a clean-room, open-source, pure-Rust presentation application targeting Microsoft
@@ -7,8 +60,7 @@ PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCra
(`../<app>`).
These rules bind every AI agent and every human contributor. They come before any task,
instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this
file wins.**
instruction or deadline. Day-to-day working instructions are in this file; `CLAUDE.md` imports it.
---
@@ -125,14 +177,14 @@ People trust DeckCraft with their talks; a crash loses work. **This outranks fea
CLI, control channel and MCP do exactly the same thing.
- **The UI is thin**: panels read engine state and act through commands. Colours come from theme
tokens. **Never use Tauri**; egui only.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Never break wasm** (`cargo xtask wasm`).
## 5. Quality gates (before every commit)
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests (nextest when installed) and doctests, layering check, asset check, wasm
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`) and push
to `main`.
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`).
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots
declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
## 6. Running and looking at the app
@@ -141,19 +193,18 @@ to `main`.
`{"id":1,"method":"engine.execute","params":{"command":"shape.insert","params":{"preset":"star5","rect":[100,100,200,200]}}}`
then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods:
`crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **For UI work, look at the result** (screenshot, read the PNG) and compare with the observations
in `plan/powerpoint/01-observed-ui.md`.
- **For UI work, look at the result** (screenshot, read the PNG). If available, compare with the observations
in `plan/powerpoint/01-observed-ui.md`. Otherwise verify the intended behaviour against the task and
committed documentation; the optional notebook is not a prerequisite.
- Headless: `deckcraft-cli render --sample --all --scale 1 out/`,
`deckcraft-cli run --sample --cmd 'slide.new={"title":"Hi"}' --export out.png`.
- MCP: `deckcraft-cli mcp` (headless) or `deckcraft-cli mcp --connect 7990` (drive the app).
See `docs/mcp.md`.
- Shell gotcha on the dev machine: `mv`/`cp` are aliased interactive; use `/bin/mv -f`, `/bin/cp -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own.
## 7. Planning
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
`plan/` is an optional gitignored notebook (status, execution notes, `powerpoint/` observations). Do not ask for it and do not stop when it is absent. The committed progress
docs:
| File | Holds |
+1 -25
View File
@@ -1,25 +1 @@
# DeckCraft — working notes for Claude
Read [AGENTS.md](AGENTS.md) first: its rules (asset policy, clean room, never crash) bind you and
win over anything here.
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
before modules or other crate items; crate-level `//!` documentation may precede them.
## Start every session
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
2. Run `cargo xtask ci` before you change anything, so you know the tree is green.
## Habits
- Every user-visible action is a command with tests (`crates/engine/src/cmd/*`).
- Look at UI changes: `deckcraft --sample --control 7990` + `ui.screenshot`, or
`deckcraft-cli render`.
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
- Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current.
- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
@AGENTS.md
Generated
+107 -5
View File
@@ -565,18 +565,42 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "calloop"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.5",
"tracing",
]
[[package]]
name = "calloop-wayland-source"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
dependencies = [
"calloop",
"calloop 0.13.0",
"rustix 0.38.44",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "calloop-wayland-source"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop 0.14.5",
"rustix 1.1.5",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "cast"
version = "0.3.0"
@@ -911,6 +935,15 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1298,6 +1331,7 @@ dependencies = [
name = "deckcraft-web"
version = "0.15.0"
dependencies = [
"craft-web-host",
"deckcraft-engine",
"deckcraft-ui-egui",
"eframe",
@@ -1508,13 +1542,16 @@ dependencies = [
"block2 0.6.2",
"bytemuck",
"egui",
"image",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"parking_lot",
"profiling",
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
"windows-sys 0.61.2",
@@ -4437,8 +4474,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
dependencies = [
"bitflags 2.13.2",
"calloop",
"calloop-wayland-source",
"calloop 0.13.0",
"calloop-wayland-source 0.3.0",
"cursor-icon",
"libc",
"log",
@@ -4455,6 +4492,44 @@ dependencies = [
"xkeysym",
]
[[package]]
name = "smithay-client-toolkit"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
dependencies = [
"bitflags 2.13.2",
"calloop 0.14.5",
"calloop-wayland-source 0.4.1",
"cursor-icon",
"libc",
"log",
"memmap2",
"rustix 1.1.5",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
"wayland-csd-frame",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-experimental",
"wayland-protocols-misc",
"wayland-protocols-wlr",
"wayland-scanner",
"xkeysym",
]
[[package]]
name = "smithay-clipboard"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
dependencies = [
"libc",
"smithay-client-toolkit 0.20.0",
"wayland-backend",
]
[[package]]
name = "smol_str"
version = "0.2.2"
@@ -4962,6 +5037,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -5404,6 +5480,32 @@ dependencies = [
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-experimental"
version = "20250721.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-misc"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-plasma"
version = "0.3.12"
@@ -6014,7 +6116,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
"bytemuck",
"calloop",
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
"core-foundation 0.9.4",
@@ -6035,7 +6137,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"smithay-client-toolkit",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
"unicode-segmentation",
+6
View File
@@ -7,6 +7,7 @@
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (progress log: fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.15.0; pasted and duplicated placeholders keep their look; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -155,6 +156,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|---|---|
| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered |
| 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop |
| 2026-11-02 | Slide show on Linux and the BSDs: Presenter View stays off when winit lists exactly one display, as in PowerPoint; if it lists none, the setting is kept. On one display the presenter window covered the show, and the hidden window stalled every frame: slide changes took up to ~1.7 s under Hyprland (#1156) |
| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order |
| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves |
| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged |
@@ -164,6 +166,9 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves |
| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables |
| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) |
| 2026-11-01 | Ctrl+V of a picture copied in a browser (Copy Image) pastes the picture, not a text box with its link and alt text; the link still pastes as text when no picture came with it |
| 2026-11-01 | Copy, Cut, Paste, Duplicate and Alt+drag of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text |
| 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows |
| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) |
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
@@ -341,6 +346,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-11-03 | trivial | Progress log: web `?host=parent` bridge to a framing page; scores unchanged |
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged |
| 2026-11-01 | trivial | Workflow-breaking regression fixed: the desktop build uses the system clipboard again (eframe `clipboard` feature) |
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
+2
View File
@@ -16,6 +16,8 @@ path = "src/main.rs"
[dependencies]
deckcraft-engine = { workspace = true }
deckcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
+64 -107
View File
@@ -1,17 +1,21 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! DeckCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's, WordCraft's and PdfCraft's bridges; each is a plain object with a
//! `type`:
//! The host bridge, for pages that embed DeckCraft and keep its files (a Nextcloud app, a personal
//! file server). The messages and the two transports are `craft-web-host`'s
//! (storytold/craft-libs), the same contract as the other Craft apps' bridges, with the
//! `deckcraft:` prefix. This module is DeckCraft's part of it.
//!
//! - `deckcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `deckcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! - `?host=parent` (an `<iframe>`) talks to the same-origin parent over `postMessage`;
//! `data-host="page"` on the canvas (the page's own document, no iframe) uses DOM
//! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `deckcraft:ready` (to the host): sent once the app is running and listening; send a file now. DeckCraft
//! reads bytes, so it says `file: false` and a host sends `bytes`.
//! - `deckcraft:open` (from the host): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`, name a bare file name. It opens like a file picked with File › Open on the
//! desktop: a presentation sent with its name (.pptx, .potx, .ppsx, .deckcraft) has that name
//! as its file, so Save goes back to it; an outline (.txt, .md, .rtf), a file without a name and
//! a damaged file open untitled.
//! - `deckcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file DeckCraft would otherwise download. The parent stores it and reports failures
//! - `deckcraft:save` (to the host): `{ name, saveAs, bytes }` (with the bytes as a transferred `ArrayBuffer` over `postMessage`) for
//! every file DeckCraft would otherwise download. The host stores it and reports failures
//! itself. `saveAs` says whether the file becomes the presentation's file:
//! - Save: `false`. It writes the opened file under its own name and format (a .pptx stays a
//! .pptx), as the desktop app does. A name the parent never sent (an untitled presentation, an
@@ -22,59 +26,56 @@
//! now that file; the next Save sends its name with `false`.
//! - Export (PDF, pictures, outline, GIF, handouts, a Save As type that isn't a presentation)
//! and Print: `false`. They are copies; the presentation's file stays the same.
//! - `deckcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `deckcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//! - `deckcraft:dirty` whenever unsaved changes appear or go away, `deckcraft:failed` when the
//! app couldn't start or refused a file of 2 GiB or larger.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the app
//! starts with an empty presentation instead of the sample, which gives way to the first file the
//! parent sends; the parent guards leaving the page, and `?author=` names the user for comments.
//! In host mode the app starts with an empty presentation instead of the sample, which gives way to
//! the first file the host sends; the host guards leaving the page, and `?author=` names the user
//! for comments.
use std::cell::{Cell, RefCell};
use std::cell::RefCell;
use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
use deckcraft_ui_egui::{Services, SlideApp};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// The app id in the host messages (`deckcraft:ready`).
const APP: &str = "deckcraft";
/// The name `craft-web-host` gives an open request that carries none (it cleans a host's name and
/// falls back to this): no name is no file, so the presentation opens untitled.
const NAMELESS: &str = "document";
/// The largest file a host may send. File › Open on the desktop refuses anything over 2 GiB.
/// On wasm32 a `Vec` cannot hold 2 GiB (`isize::MAX` is one byte less), so a file of exactly
/// 2 GiB is refused too, before its bytes are copied.
const MAX_OPEN_BYTES: f64 = 2_147_483_648.0;
/// The longest user name taken from `?author=` (the preferences' own limit).
const MAX_AUTHOR_CHARS: usize = 256;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
/// Files the parent sent (the name, if it sent one), waiting for the next frame.
host: Rc<Host>,
/// Files the host sent (the name, if it sent one), waiting for the next frame.
opened: Rc<RefCell<Vec<(Option<String>, Vec<u8>)>>>,
/// The unsaved-changes state last reported to the parent.
reported: Rc<Cell<Option<bool>>>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
/// on another origin: messages only go to this origin, so the browser would drop every save
/// without an error while the presentation showed as saved.
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
/// The bridge for this page, or `None` when DeckCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
let host = Host::connect(APP, canvas)?;
// An opaque origin (a file:// page in some browsers) can't be a postMessage target.
if host.mode() == craft_web_host::Mode::Parent && web_sys::window()?.location().origin().ok()? == "null" {
return None;
}
let origin = window.location().origin().ok()?;
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
if origin == "null" {
return None;
}
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin, opened: Rc::default(), reported: Rc::default() })
Some(Self { host: Rc::new(host), opened: Rc::default() })
}
/// Hand saved and exported files to the parent instead of downloading them, and hear the
/// files it sends.
/// Hand saved and exported files to the host instead of downloading them, and hear the files
/// it sends.
pub fn connect(&self, services: &mut Services, ctx: egui::Context) {
self.listen(ctx);
let bridge = self.clone();
@@ -97,15 +98,15 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "deckcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "deckcraft:failed".into()), ("error", error.into())]);
let _ = self.host.post_failed(error, None);
}
/// Before each frame: open the files the parent sent.
/// Before each frame: open the files the host sent.
pub fn open_files(&self, app: &mut SlideApp) {
let opened = std::mem::take(&mut *self.opened.borrow_mut());
for (name, bytes) in opened {
@@ -113,80 +114,36 @@ impl Bridge {
}
}
/// After each frame: tell the parent when unsaved changes appear or go away.
/// After each frame: tell the host when unsaved changes appear or go away (the crate does not
/// repeat a state it already reported).
pub fn report_dirty(&self, app: &SlideApp) {
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
if self.reported.get() != Some(dirty) && self.send(&[("type", "deckcraft:dirty".into()), ("dirty", dirty.into())]) {
self.reported.set(Some(dirty));
}
self.host.post_dirty(app.session.documents().iter().any(|d| d.is_dirty()));
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[
("type", "deckcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
self.host.post_save(&name, bytes, None, save_as)
}
/// Hear `deckcraft:open` from the parent and queue the file for the next frame.
/// Hear `deckcraft:open` and queue the file for the next frame.
fn listen(&self, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let opened = self.opened.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
let host = self.host.clone();
self.host.listen(move |message| {
let Message::Open { name, data } = message else { return };
if data.size() >= MAX_OPEN_BYTES {
log::warn!("host file {name} is 2 GiB or larger; not opened");
let _ = host.post_failed(&format!("{name} is 2 GiB or larger, which DeckCraft's web build cannot open"), None);
return;
}
let data = event.data();
if string(&data, "type").as_deref() != Some("deckcraft:open") {
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; DeckCraft reads bytes only; not opened");
return;
}
};
// No name is no file: the presentation opens untitled, never under an invented name.
let name = string(&data, "name").filter(|n| !n.trim().is_empty());
let Some(bytes) = bytes(&data) else { return };
opened.borrow_mut().push((name, bytes));
let name = (name != NAMELESS).then_some(name);
opened.borrow_mut().push((name, array.to_vec()));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
}
}
+7 -5
View File
@@ -24,11 +24,13 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing DeckCraft opens presentations in it and
// stores what it saves (`host`). The presenter popup is never the framed page.
let host = query_param("host").as_deref() == Some("parent") && crate::presenter::token().is_none();
let bridge = if host { crate::host::Bridge::new() } else { None };
if host && bridge.is_none() {
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing DeckCraft,
// opens presentations in it and stores what it saves (`host`); `data-host="page"` on the
// canvas opts a page in that runs DeckCraft in its own document. The presenter popup is
// never the framed page.
let hosted = crate::presenter::token().is_none();
let bridge = if hosted { crate::host::Bridge::detect(&canvas) } else { None };
if hosted && bridge.is_none() && query_param("host").as_deref() == Some("parent") {
log::warn!("?host=parent needs a framing page on the same origin; saves download instead");
}
let app_bridge = bridge.clone();
+3 -1
View File
@@ -14,7 +14,9 @@ workspace = true
deckcraft-engine = { workspace = true }
deckcraft-ui-egui = { workspace = true }
deckcraft-media = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
# `clipboard` is the OS clipboard (Ctrl+C/X/V with other apps). Without it egui simulates one inside
# the app; the egui 0.37 migration (#1350) dropped it from this list.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+49 -5
View File
@@ -103,7 +103,13 @@ fn save_prefs(app: &SlideApp) {
}
}
fn services() -> Services {
/// The desktop services. `native_wayland`: the window is a Wayland client (not Xwayland).
fn services(native_wayland: bool) -> Services {
// On native Wayland, egui-winit owns the clipboard through winit's data device, which carries
// text and PNG but no HTML. `pasteboard` (arboard, built for X11 only) would put a copy on the
// Xwayland selection instead and skip egui's Wayland claim, so Ctrl+V would read something
// else. There, text goes through egui and pictures arrive as `PasteImage` events.
let pasteboard = !native_wayland;
Services {
pick_open: Some(Box::new(|purpose: &str| {
let d = rfd::FileDialog::new();
@@ -126,9 +132,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(pasteboard::image_png)),
clipboard_html: Some(Box::new(pasteboard::html)),
clipboard_set_html: Some(Box::new(pasteboard::set_html)),
clipboard_image: pasteboard.then(|| Box::new(pasteboard::image_png) as Box<dyn FnMut() -> Option<Vec<u8>>>),
clipboard_html: pasteboard.then(|| Box::new(pasteboard::html) as Box<dyn FnMut() -> Option<String>>),
clipboard_set_html: pasteboard.then(|| Box::new(pasteboard::set_html) as Box<dyn FnMut(&str, &str) -> bool>),
// 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()
@@ -137,6 +143,14 @@ fn services() -> Services {
}
}
/// Displays winit lists for `window`, counting an output Wayland lists twice once. `None` when it
/// lists none: Presenter View then stays as it was.
#[cfg(all(unix, not(target_os = "macos")))]
fn display_count(window: &winit::window::Window) -> Option<usize> {
let n = window.available_monitors().collect::<std::collections::BTreeSet<_>>().len();
(n > 0).then_some(n)
}
const APP_ID: &str = "ai.storyteller.deckcraft";
const USAGE: &str = "\
@@ -247,6 +261,11 @@ fn main() -> eframe::Result {
false
}
};
// `Default` lets winit choose, and winit takes Wayland whenever `WAYLAND_DISPLAY` is set.
#[cfg(all(unix, not(target_os = "macos")))]
let native_wayland = !x11 && std::env::var_os("WAYLAND_DISPLAY").is_some_and(|w| !w.is_empty());
#[cfg(not(all(unix, not(target_os = "macos"))))]
let native_wayland = false;
#[cfg(all(unix, not(target_os = "macos")))]
if x11 {
options.event_loop_builder = Some(Box::new(|b| {
@@ -268,7 +287,12 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
let mut app = SlideApp::new(Session::new(), services());
let mut app = SlideApp::new(Session::new(), services(native_wayland));
// Presenter View asks winit how many displays there are when a show starts.
#[cfg(all(unix, not(target_os = "macos")))]
if let Some(window) = cc.winit_window().cloned() {
app.services.displays = Some(Box::new(move || display_count(&window)));
}
load_prefs(&mut app);
app.enable_update_checks();
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
@@ -335,6 +359,26 @@ mod tests {
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
}
/// The egui 0.37 migration (#1350) left `clipboard` out of eframe's features. egui then
/// simulated a clipboard inside the app: nothing pasted from other apps, and Ctrl+C/Ctrl+V
/// lost text that went out as HTML. Keep the OS clipboard on.
#[test]
fn eframe_uses_the_os_clipboard() {
let manifest = include_str!("../Cargo.toml");
let eframe = manifest.lines().find(|l| l.starts_with("eframe = ")).unwrap_or_default();
assert!(eframe.contains("\"clipboard\""), "eframe must enable the OS clipboard: {eframe}");
}
/// On native Wayland, the X11-only pasteboard would bypass egui's Wayland clipboard; elsewhere
/// it carries HTML and pictures beside egui's text.
#[test]
fn the_pasteboard_stays_off_native_wayland() {
let s = services(true);
assert!(s.clipboard_html.is_none() && s.clipboard_set_html.is_none() && s.clipboard_image.is_none());
let s = services(false);
assert!(s.clipboard_html.is_some() && s.clipboard_set_html.is_some() && s.clipboard_image.is_some());
}
#[test]
fn help_exits_instead_of_opening_a_file() {
assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
+45 -16
View File
@@ -107,7 +107,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
return Ok(plain);
}
let shapes: Vec<Shape> = st
let mut shapes: Vec<Shape> = st
.selection
.shapes
.iter()
@@ -120,14 +120,33 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
})
})
.collect();
// Copying is the last chance to read what a placeholder inherits: a cut removes it, and the
// paste can be in another deck. Slide Master view pastes the originals instead.
let placeholders: Vec<Shape> = shapes.iter().filter(|x| x.ph.is_some()).cloned().collect();
detach_on_slide(&st.doc, &st.selection, &mut shapes);
let mids = crate::media_refs::shapes_media(&shapes);
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() };
// The theme of what is being edited: a slide's or layout's colour map, else the master's.
let source_theme = super::text::ctx_for(&st.doc, &st.selection).map(|ctx| crate::SourceTheme {
colors: ctx.master.theme.colors.clone(),
color_map: ctx.color_map.clone(),
fonts: ctx.master.theme.fonts.clone(),
});
s.clipboard = crate::Clipboard { shapes, placeholders, media, plain: plain.clone(), source_theme, ..Default::default() };
Ok(plain)
}
/// On a slide, a copy of a placeholder is a regular shape: give `shapes` the look they inherit
/// there. In Slide Master view a copy stays a placeholder, so this changes nothing.
pub(crate) fn detach_on_slide(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shapes: &mut [Shape]) {
if sel.target == crate::Target::Slides
&& let Some(ctx) = super::text::ctx_for(doc, sel)
{
shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh));
}
}
fn copying_caret(s: &Session) -> bool {
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
}
@@ -319,6 +338,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
return s.edit(|doc, sel| {
let media = bring_media("edit.paste", doc, &clip.media)?;
let mut shapes = clip.shapes.clone();
if sel.target != crate::Target::Slides {
for sh in &mut shapes {
if let Some(original) = clip.placeholders.iter().find(|p| p.id == sh.id) {
sh.clone_from(original);
}
}
}
reid("edit.paste", doc, &mut shapes)?;
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
let (mut taken, mut count) = super::current_names(doc, sel);
@@ -340,7 +366,8 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
x.x += off;
x.y += off;
}
// A pasted placeholder becomes a regular shape keeping its look.
// A placeholder pasted on a slide becomes a regular shape. One copied from a slide
// already has its look; see `copy_inner`.
if sh.ph.is_some() && sel.target == crate::Target::Slides {
sh.ph = None;
}
@@ -404,8 +431,9 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
"text" | "keepTextOnly" => paste_text(s, &json!({})),
"keepSource" | "keepSourceFormatting" => {
let saved = s.clipboard.clone();
if let Some((scheme, fonts)) = &saved.source_theme {
bake_theme(&mut s.clipboard.shapes, scheme, fonts);
if let Some(theme) = &saved.source_theme {
bake_theme(&mut s.clipboard.shapes, theme);
bake_theme(&mut s.clipboard.placeholders, theme);
}
let r = paste(s, &json!({}));
let pastes = s.clipboard.pastes;
@@ -420,8 +448,8 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
/// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the
/// shapes keep their look under another theme.
pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) {
fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) {
pub(crate) fn bake_theme(shapes: &mut [Shape], theme: &crate::SourceTheme) {
fn rec(v: &mut Value, theme: &crate::SourceTheme, depth: usize) {
if depth > 256 {
return;
}
@@ -430,7 +458,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
if o.get("kind").and_then(Value::as_str) == Some("scheme")
&& let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::<deckcraft_color::SchemeSlot>(x.clone()).ok())
&& slot != deckcraft_color::SchemeSlot::PhClr
&& let Ok(rgb) = serde_json::to_value(scheme.get(slot))
&& let Ok(rgb) = serde_json::to_value(theme.colors.get(deckcraft_model::map_slot(&theme.color_map, slot)))
{
o.clear();
o.insert("kind".into(), json!("rgb"));
@@ -438,14 +466,14 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
return;
}
for x in o.values_mut() {
rec(x, scheme, fonts, depth + 1);
rec(x, theme, depth + 1);
}
}
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)),
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, theme, depth + 1)),
Value::String(t) if t.starts_with('+') => {
let set = match t.get(1..3) {
Some("mj") => &fonts.major,
Some("mn") => &fonts.minor,
Some("mj") => &theme.fonts.major,
Some("mn") => &theme.fonts.minor,
_ => return,
};
let face = match t.get(3..) {
@@ -463,7 +491,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
}
for sh in shapes.iter_mut() {
let Ok(mut v) = serde_json::to_value(&*sh) else { continue };
rec(&mut v, scheme, fonts, 0);
rec(&mut v, theme, 0);
if let Ok(baked) = serde_json::from_value::<Shape>(v) {
*sh = baked;
}
@@ -476,8 +504,8 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
if clip.shapes.is_empty() {
return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture"));
}
if let Some((scheme, fonts)) = &clip.source_theme {
bake_theme(&mut clip.shapes, scheme, fonts);
if let Some(theme) = &clip.source_theme {
bake_theme(&mut clip.shapes, theme);
}
let st = s.doc()?;
let mut doc = (*st.doc).clone();
@@ -533,6 +561,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
detach_on_slide(doc, sel, &mut copies);
// Duplicating the copies of the last Duplicate again repeats how far they were moved
// since (#966); anything else gets the usual offset.
let (dx, dy) = last
+16 -3
View File
@@ -454,17 +454,30 @@ pub struct MediaStatus {
}
/// Clipboard contents copied within the app.
/// The theme a copy drew with. The colours and the map stay apart, as in
/// `deckcraft_model::resolve::Ctx::color`: the map renames only `tx1`, `bg1`, `tx2` and `bg2`, and
/// a direct `dk1` keeps the theme's colour.
#[derive(Clone, Debug)]
pub struct SourceTheme {
/// The theme's own colours, no colour map applied.
pub colors: deckcraft_color::ColorScheme,
pub color_map: Vec<(String, String)>,
pub fonts: deckcraft_model::theme::FontScheme,
}
#[derive(Clone, Debug, Default)]
pub struct Clipboard {
pub shapes: Vec<Shape>,
/// The copied placeholders as they were, by id. `shapes` holds a slide's placeholders as
/// regular shapes; a paste in Slide Master view takes these, which stay placeholders.
pub placeholders: Vec<Shape>,
pub slides: Vec<Slide>,
/// Media the copied shapes/slides reference.
pub media: Vec<deckcraft_model::MediaItem>,
pub text: Option<deckcraft_model::TextBody>,
pub plain: String,
/// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special ›
/// Keep Source Formatting.
pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>,
/// The theme the shapes were copied under, for Paste Special › Keep Source Formatting.
pub source_theme: Option<SourceTheme>,
/// How many times the shapes were pasted (each paste offsets further).
pub pastes: u32,
}
+362
View File
@@ -214,6 +214,368 @@ fn clipboard_copy_paste_shapes_and_slides() {
assert!(s.doc().unwrap().doc.validate().is_empty());
}
/// The resolved look of every paragraph and run of shape `id` on the current slide: size, font,
/// colour, bold, bullet, indent, alignment, and the box's insets and anchor.
fn resolved_look(s: &Session, id: ShapeId) -> String {
use deckcraft_model::resolve;
let st = s.doc().unwrap();
let slide = st.current_slide().unwrap();
let ctx = resolve::Ctx::for_slide(&st.doc, slide).unwrap();
let sh = st.shape(id).unwrap();
let t = sh.text.as_ref().unwrap();
let b = resolve::body(&ctx, sh);
let mut out = format!("body {:?} {:?} {:?}\n", b.anchor, b.inset_l, b.autofit);
for p in &t.paragraphs {
let pp = resolve::para(&ctx, sh, p);
out += &format!("para {:?} {:?} {:?} {:?}\n", pp.align, pp.margin_left, pp.indent, pp.bullet);
for r in &p.runs {
let rp = resolve::run(&ctx, sh, p, &r.props);
out += &format!(" run {:?} {:?} {:?} {:?}\n", rp.size, rp.font, rp.bold, rp.fill);
}
}
out
}
/// A placeholder pasted or duplicated on a slide becomes a regular shape, and keeps the look its
/// layout and master gave it: the title's size and font, the body's bullets and indents. Before,
/// the copy fell back to the master's other-text style (18 pt, no bullets).
#[test]
fn a_pasted_or_duplicated_placeholder_keeps_its_inherited_look() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let phs: Vec<ShapeId> = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect();
assert!(phs.len() >= 2, "a title and a body placeholder");
for (k, id) in phs.iter().enumerate() {
s.execute("text.set", &json!({"id": id.0, "text": format!("Placeholder {k}\nSecond line")})).unwrap();
}
for id in phs {
let want = resolved_look(&s, id);
for cmd in ["paste", "duplicate"] {
s.execute("selection.select", &json!({"id": id.0})).unwrap();
if cmd == "paste" {
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
} else {
s.execute("edit.duplicate", &json!({})).unwrap();
}
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, id);
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "{cmd}: the copy is a regular shape");
assert_eq!(resolved_look(&s, copy), want, "{cmd} of placeholder {}", id.0);
}
}
assert!(s.doc().unwrap().doc.validate().is_empty());
}
/// Dragging a copy of a placeholder off it keeps the look too: the title's size and font, the
/// body's bullets. `edit.duplicate` is not the only way to duplicate.
#[test]
fn a_placeholder_duplicated_by_dragging_keeps_its_look() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let phs: Vec<ShapeId> = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect();
assert!(phs.len() >= 2, "a title and a body placeholder");
for id in phs {
s.execute("text.set", &json!({"id": id.0, "text": "First\nSecond"})).unwrap();
let want = resolved_look(&s, id);
s.execute("selection.select", &json!({"id": id.0})).unwrap();
let st = s.doc().unwrap();
let x = crate::cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap());
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
let alt = |kind, x, y| PointerEvent { mods: Mods { alt: true, ..Default::default() }, ..tev(kind, x, y) };
s.pointer(alt(PointerKind::Down, cx, cy)).unwrap();
s.pointer(alt(PointerKind::Drag, cx + 30.0, cy + 30.0)).unwrap();
s.pointer(alt(PointerKind::Up, cx + 30.0, cy + 30.0)).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, id, "the drag made a copy");
assert!(s.doc().unwrap().shape(id).unwrap().ph.is_some(), "the original stays a placeholder");
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "the copy is a regular shape");
assert_eq!(resolved_look(&s, copy), want, "placeholder {}", id.0);
}
}
/// Keep Source Formatting fixes a copy's theme colours as the source drew them: through the colour
/// map of its slide or layout, not the master's. A title's inherited `tx1` stays the light colour
/// an override gave it.
#[test]
fn keep_source_formatting_uses_the_slides_colour_map() {
use deckcraft_model::resolve;
let first_run_colour = |s: &Session, id: ShapeId| {
let st = s.doc().unwrap();
let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap();
let sh = st.shape(id).unwrap();
let p = &sh.text.as_ref().unwrap().paragraphs[0];
resolve::text_color(&ctx, &resolve::run(&ctx, sh, p, &p.runs[0].props))
};
for on_layout in [false, true] {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id;
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
let plain = first_run_colour(&s, title);
let st = s.doc_mut().unwrap();
let at = st.selection.slide;
let doc = std::sync::Arc::make_mut(&mut st.doc);
if on_layout {
let layout = doc.slides[at].layout;
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = swapped_colour_map();
} else {
std::sync::Arc::make_mut(&mut doc.slides[at]).color_map = swapped_colour_map();
}
let want = first_run_colour(&s, title);
assert_ne!(want, plain, "the override changes how the title draws");
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, title);
assert_eq!(first_run_colour(&s, copy), want, "override on the layout: {on_layout}");
}
}
/// A colour map that swaps text and background, as a dark slide or layout has.
fn swapped_colour_map() -> Option<Vec<(String, String)>> {
Some([("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].map(|(a, b)| (a.to_string(), b.to_string())).to_vec())
}
/// A new rectangle in the edited tree, filled with theme colour `slot`.
fn rect_filled_with(s: &mut Session, slot: deckcraft_color::SchemeSlot) -> ShapeId {
use deckcraft_model::{ColorRef, Fill};
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let id = ShapeId(u32::try_from(id).unwrap());
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).unwrap();
list.iter_mut().find(|x| x.id == id).unwrap().fill = Some(Fill::solid(ColorRef::scheme(slot)));
Ok(())
})
.unwrap();
id
}
/// How shape `id`'s fill draws in the edited tree.
fn drawn_fill(s: &Session, id: ShapeId) -> deckcraft_color::Rgba {
use deckcraft_model::{Fill, resolve};
let st = s.doc().unwrap();
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection).unwrap();
match resolve::fill(&ctx, st.shape(id).unwrap()).0 {
Some(Fill::Solid { color }) => ctx.color(&color, None),
other => panic!("not a solid fill: {other:?}"),
}
}
/// Copy the selected shape and paste it with Keep Source Formatting; the copy's id.
fn keep_source_copy(s: &mut Session, id: ShapeId) -> ShapeId {
s.execute("selection.select", &json!({"id": id.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, id);
copy
}
/// A colour map renames only `tx1`, `bg1`, `tx2` and `bg2`. Keep Source Formatting fixes every slot
/// as it draws: `tx1` follows the map, while a direct `dk1` stays the theme's dark colour.
#[test]
fn keep_source_formatting_maps_only_the_alias_colours() {
use deckcraft_color::SchemeSlot::{self, *};
let mut s = session();
let st = s.doc_mut().unwrap();
let at = st.selection.slide;
std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map();
let slots: [SchemeSlot; 9] = [Dk1, Lt1, Dk2, Lt2, Tx1, Bg1, Tx2, Bg2, Accent1];
let mut drawn = vec![];
for slot in slots {
let id = rect_filled_with(&mut s, slot);
let want = drawn_fill(&s, id);
let copy = keep_source_copy(&mut s, id);
assert_eq!(drawn_fill(&s, copy), want, "{slot:?}");
drawn.push(want);
}
assert_ne!(drawn[0], drawn[4], "the map sets `tx1` apart from `dk1`");
}
/// In Slide Master view a copy draws through the edited master's map, not an override on the slide
/// that happens to be current.
#[test]
fn keep_source_formatting_in_slide_master_view_ignores_the_slides_colour_map() {
use deckcraft_color::SchemeSlot::Tx1;
for master in [false, true] {
let mut s = session();
let st = s.doc_mut().unwrap();
let at = st.selection.slide;
std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map();
s.execute("view.slideMaster", &if master { json!({"master": 0}) } else { json!({}) }).unwrap();
let id = rect_filled_with(&mut s, Tx1);
let want = drawn_fill(&s, id);
let copy = keep_source_copy(&mut s, id);
assert_eq!(drawn_fill(&s, copy), want, "editing the master: {master}");
}
}
/// A slide's placeholder pasted in Slide Master view stays a placeholder that inherits from where
/// it lands; only a paste on a slide turns it into a regular shape with the look it had.
#[test]
fn a_slide_placeholder_pasted_in_slide_master_view_stays_a_placeholder() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id;
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
let want = resolved_look(&s, title);
let source = s.doc().unwrap().shape(title).unwrap().clone();
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("view.slideMaster", &json!({})).unwrap();
for paste in ["edit.paste", "edit.pasteSpecial"] {
s.execute(paste, &json!({"as": "keepSource"})).unwrap();
let st = s.doc().unwrap();
let copy = st.shape(st.selection.shapes[0]).unwrap();
assert_eq!(copy.ph.as_ref().map(|p| p.kind), source.ph.as_ref().map(|p| p.kind), "{paste} keeps the placeholder");
let list = copy.text.as_ref().map(|t| t.list_style.clone());
assert_eq!(list, source.text.as_ref().map(|t| t.list_style.clone()), "{paste} adds no baked styles");
}
s.execute("view.closeMaster", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "on a slide the copy is a regular shape");
assert_eq!(resolved_look(&s, copy), want);
}
/// The resolved fill, line and effects of shape `id` on the current slide, with their style colours.
fn resolved_box(s: &Session, id: ShapeId) -> String {
use deckcraft_model::resolve;
let st = s.doc().unwrap();
let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap();
let sh = st.shape(id).unwrap();
format!("{:?}\n{:?}\n{:?}", resolve::fill(&ctx, sh), resolve::line(&ctx, sh), resolve::effects(&ctx, sh))
}
/// Edit the title placeholders the current slide's title inherits from: its layout's and its
/// master's. Returns the slide title's id.
fn edit_title_parents(
s: &mut Session,
layout: impl FnOnce(&mut deckcraft_model::Shape),
master: impl FnOnce(&mut deckcraft_model::Shape),
) -> ShapeId {
let st = s.doc_mut().unwrap();
let slide = st.current_slide().unwrap().clone();
let title = slide.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap().id;
let doc = std::sync::Arc::make_mut(&mut st.doc);
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
let lay = m.layouts.iter_mut().find(|l| l.id == slide.layout).unwrap();
layout(lay.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap());
master(m.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap());
title
}
/// A copied placeholder takes each of fill, line and effects from where the original does: a
/// style on the layout's placeholder (or on the slide's own) wins over an explicit value on the
/// master's, as `resolve::fill`, `resolve::line` and `resolve::effects` decide.
#[test]
fn a_pasted_placeholder_keeps_the_style_precedence_of_its_box() {
use deckcraft_color::SchemeSlot;
use deckcraft_model::{ColorRef, Effects, Fill, Line, ShapeStyle};
for style_on_slide in [false, true] {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let style = ShapeStyle {
line_ref: (1, ColorRef::scheme(SchemeSlot::Accent2)),
fill_ref: (1, ColorRef::scheme(SchemeSlot::Accent1)),
effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)),
font_ref: ("minor".into(), None),
};
let layout_style = (!style_on_slide).then(|| style.clone());
let title = edit_title_parents(
&mut s,
|l| l.style = layout_style,
|m| {
m.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Accent4)));
m.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent5), 20.0));
m.effects = Some(Effects { soft_edge: Some(5.0), ..Default::default() });
},
);
if style_on_slide {
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).unwrap();
list.iter_mut().find(|x| x.id == title).unwrap().style = Some(style.clone());
Ok(())
})
.unwrap();
}
s.execute("text.set", &json!({"id": title.0, "text": "Styled title"})).unwrap();
let want = (resolved_box(&s, title), resolved_look(&s, title));
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want, "style on the slide: {style_on_slide}");
}
}
/// The master's placeholder style never reaches the copy's text: the original's text ignores it
/// (only the shape's or the layout's font reference counts), so the copy must too.
#[test]
fn a_pasted_placeholder_ignores_the_masters_font_reference() {
use deckcraft_color::SchemeSlot;
use deckcraft_model::{ColorRef, ShapeStyle};
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let title = edit_title_parents(
&mut s,
|l| l.style = None,
|m| {
m.style = Some(ShapeStyle {
line_ref: (0, ColorRef::scheme(SchemeSlot::Accent2)),
fill_ref: (0, ColorRef::scheme(SchemeSlot::Accent1)),
effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)),
font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Accent6))),
})
},
);
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
let want = (resolved_box(&s, title), resolved_look(&s, title));
assert!(want.1.contains("+mj-lt"), "the title draws in the heading font: {}", want.1);
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.duplicate", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want);
}
/// The same on every text placeholder of the sample deck (titles, bulleted bodies, subtitles).
#[test]
fn sample_placeholders_paste_with_their_look() {
let mut s = Session::new();
crate::sample::open_sample(&mut s).unwrap();
let slides = s.doc().unwrap().doc.slides.len();
let mut checked = 0;
for i in 0..slides {
s.execute("slide.go", &json!({"index": i})).unwrap();
let phs: Vec<ShapeId> = s
.doc()
.unwrap()
.current_slide()
.unwrap()
.shapes
.iter()
.filter(|x| x.ph.is_some() && x.text.as_ref().is_some_and(|t| !t.is_empty()))
.map(|x| x.id)
.collect();
for id in phs {
let want = resolved_look(&s, id);
s.execute("selection.select", &json!({"id": id.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_eq!(resolved_look(&s, copy), want, "slide {i}, placeholder {}", id.0);
checked += 1;
}
}
assert!(checked >= 3, "the sample has text placeholders ({checked})");
}
#[test]
fn pasted_shapes_get_the_next_automatic_name() {
let mut s = session();
+1
View File
@@ -101,6 +101,7 @@ impl Session {
let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default();
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
let boxes: Vec<Xfrm> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
cmd::edit::detach_on_slide(doc, sel, &mut copies);
cmd::edit::reid("edit.duplicate", doc, &mut copies)?;
for (c, b) in copies.iter_mut().zip(boxes) {
c.xfrm = Some(b);
+63 -1
View File
@@ -4,7 +4,7 @@
use deckcraft_color::{ColorScheme, Rgba, SchemeSlot};
use deckcraft_geom::Xfrm;
use crate::style::{ColorRef, Effects, Fill, Line};
use crate::style::{ColorRef, Effects, Fill, Line, ShapeStyle};
use crate::text::{Anchor, AutoFit, BodyProps, LevelStyle, ListStyle, ParaProps, RunProps};
use crate::{Background, Layout, Master, PhType, Presentation, Shape, Slide};
@@ -218,6 +218,68 @@ pub fn effects(ctx: &Ctx, shape: &Shape) -> (Option<Effects>, Option<Rgba>) {
(None, None)
}
/// Turn a placeholder into a regular shape that looks the same: what it inherited from its layout
/// and master placeholders (position, fill, line, effects, style, body properties and the text
/// style of every outline level, the master's title or body style included) becomes its own, and
/// `ph` is cleared. A shape that is not a placeholder is left as it is.
///
/// Theme references (`+mj-lt`, scheme colours) stay references, so the copy still follows the
/// destination theme, as PowerPoint's default paste does.
pub fn detach_placeholder(ctx: &Ctx, shape: &mut Shape) {
if shape.ph.is_none() {
return;
}
// The text first: `level_style` reads the shape's own style, which changes below.
let text = shape.text.as_ref().map(|_| (body(ctx, shape), (0..9).map(|l| Some(level_style(ctx, shape, l))).collect::<Vec<_>>()));
let own = shape.clone();
let (lp, mp) = parents(ctx, &own);
let chain = [Some(&own), lp, mp].into_iter().flatten();
// Each property comes from where `fill`, `line` and `effects` take it: the first shape in the
// chain with an explicit value or a style. Lines merge explicit values up to the first styled
// shape, whose style line fills the rest.
let fill_src = chain.clone().find(|s| s.fill.is_some() || s.style.is_some());
let effects_src = chain.clone().find(|s| s.effects.is_some() || s.style.is_some());
let mut line = None;
let mut line_src = None;
for s in chain.clone() {
if let Some(l) = &s.line {
line.get_or_insert_with(Line::default).inherit(l);
}
if s.style.is_some() {
line_src = Some(s);
break;
}
}
// Text takes its font reference from the shape's style or its layout placeholder's (a table's
// from its own only), never the master's.
let table = matches!(own.kind, crate::ShapeKind::Table(_));
let font_src = own.style.as_ref().or(lp.filter(|_| !table).and_then(|s| s.style.as_ref()));
// One style for the copy, each reference taken from the shape that supplied that property. A
// property with an explicit value ignores its reference, except a gradient without stops,
// which takes them from its own shape's style; a neutral reference (index 0) adds nothing.
let style_of = |s: Option<&Shape>| s.and_then(|s| s.style.clone());
let neutral = (0, ColorRef::scheme(SchemeSlot::Tx1));
let style = chain.clone().any(|s| s.style.is_some()).then(|| ShapeStyle {
fill_ref: style_of(fill_src).map_or_else(|| neutral.clone(), |st| st.fill_ref),
line_ref: style_of(line_src).map_or_else(|| neutral.clone(), |st| st.line_ref),
effect_ref: style_of(effects_src).map_or_else(|| neutral.clone(), |st| st.effect_ref),
font_ref: font_src.map_or_else(|| ("none".into(), None), |st| st.font_ref.clone()),
});
shape.xfrm = Some(xfrm(ctx, &own));
shape.fill = fill_src.and_then(|s| s.fill.clone());
shape.effects = effects_src.and_then(|s| s.effects.clone());
shape.line = line;
shape.style = style;
if let (Some(t), Some((body, levels))) = (shape.text.as_mut(), text) {
t.body = body;
t.list_style = ListStyle { levels };
}
shape.ph = None;
}
/// Body properties with defaults filled in.
pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
let (lp, mp) = parents(ctx, shape);
+46
View File
@@ -11,6 +11,52 @@ pub(crate) fn system_clipboard_text(t: &str) -> String {
if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() }
}
/// Whether pasted `text` (and the clipboard's `html`) only name a picture that is on the clipboard
/// too, as a browser's Copy Image leaves them: the picture's address, perhaps followed by its alt
/// text, or HTML that is a lone `<img>`. Then Ctrl+V pastes the picture, as PowerPoint does.
/// Text with NULs is UTF-16 read as UTF-8 (Firefox's `text/x-moz-url`), never words to keep.
pub(crate) fn names_a_picture(text: &str, html: Option<&str>) -> bool {
if text.contains('\0') {
return true;
}
if let Some(h) = html
&& h.to_ascii_lowercase().contains("<img")
&& visible_text(h).trim().is_empty()
{
return true;
}
let mut lines = text.trim().lines();
let first = lines.next().unwrap_or("").trim();
let link = ["http://", "https://", "file://", "data:image/"].iter().any(|p| first.to_ascii_lowercase().starts_with(p));
link && !first.contains(char::is_whitespace) && lines.count() <= 1
}
/// The text of an HTML fragment outside its tags (and outside comments and `<style>`/`<script>`
/// bodies, which browsers sometimes put around a copied picture).
fn visible_text(html: &str) -> String {
let lower = html.to_ascii_lowercase();
let mut out = String::new();
let mut i = 0;
while i < html.len() {
let rest = &lower[i..];
let skip_to = |end: &str| rest.find(end).map(|e| i + e + end.len()).unwrap_or(html.len());
if rest.starts_with("<!--") {
i = skip_to("-->");
} else if rest.starts_with("<style") {
i = skip_to("</style>");
} else if rest.starts_with("<script") {
i = skip_to("</script>");
} else if rest.starts_with('<') {
i = skip_to(">");
} else {
let end = rest.find('<').map_or(html.len(), |e| i + e);
out.push_str(&html[i..end]);
i = end;
}
}
out.replace("&nbsp;", " ")
}
impl SlideApp {
/// Offer the copy or cut on the system clipboard.
///
+4 -2
View File
@@ -152,8 +152,10 @@ impl SlideApp {
// Text keeps its HTML (bold, italic, underline) when the host can read it.
let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty());
// egui 0.37 sends `PasteImage` directly; the services fallback stays for
// integrations that still send an empty `Paste`. Never insert twice.
let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };
// integrations that still send an empty `Paste`, and for a browser's Copy
// Image, whose text is only the picture's link. Never insert twice.
let wants_picture = text.is_empty() || super::clipboard::names_a_picture(&text, html.as_deref());
let img = if wants_picture && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };
let revision = self.session.active().map(|d| d.revision);
let r = match img {
Some(png) => self
+10 -1
View File
@@ -32,15 +32,24 @@ impl SlideApp {
pub fn start_show(&mut self, from: usize, reading: bool) {
self.anim_preview = None;
self.media.stop_owned(media::Owner::Editor);
let presenter = self.ui.presenter_view && !reading && self.second_display();
if let Some(d) = self.session.active() {
self.show_instance = self.show_instance.wrapping_add(1).max(1);
let mut show = show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading);
let mut show = show::Show::new(&d.doc, from, reading, presenter);
show.instance = self.show_instance;
show.keep_cursor = self.ui.show_keep_cursor;
self.show = Some(show);
}
}
/// Whether a second display can take Presenter View. Like PowerPoint, one display shows only
/// the slides: the presenter window would open on the same screen, and whichever of the two
/// windows ends up behind the other stalls the show (the compositor stops pacing a hidden
/// window). Hosts that can't count displays keep Presenter View.
fn second_display(&mut self) -> bool {
self.services.displays.as_mut().and_then(|f| f()).is_none_or(|n| n >= 2)
}
/// Play custom show `name` from its first slide. Set Up Slide Show is left as it was.
pub fn start_custom_show(&mut self, name: &str, reading: bool) {
let state = self.session.active().and_then(|d| deckcraft_anim::ShowState::play_custom(&d.doc, name));
+17
View File
@@ -16,3 +16,20 @@ fn show_presenter_answers_on_and_window() {
let doc = crate::UI_COMMANDS.iter().find(|c| c.0 == "show.presenter").unwrap().3;
assert!(doc.contains("separate window"), "{doc}");
}
#[test]
fn presenter_view_needs_a_second_display() {
// One display: the presenter window covered the show (or the show covered it) and the
// hidden window stalled every frame.
let presenter = |displays: Option<usize>, setting: bool| {
let services = Services { displays: Some(Box::new(move || displays)), ..Default::default() };
let mut app = SlideApp::new(Session::with_new(), services);
app.ui.presenter_view = setting;
app.start_show(0, false);
app.show.as_ref().map(|s| s.presenter)
};
assert_eq!(presenter(Some(1), true), Some(false));
assert_eq!(presenter(Some(2), true), Some(true));
assert_eq!(presenter(None, true), Some(true), "a host that can't count displays keeps Presenter View");
assert_eq!(presenter(Some(2), false), Some(false), "the setting still turns it off");
}
+2
View File
@@ -71,4 +71,6 @@ pub struct Services {
pub clipboard_set_html: Option<Box<dyn FnMut(&str, &str) -> bool>>,
/// Audio output for media playback (cpal on desktop); without one media plays silently.
pub audio_out: Option<Box<dyn deckcraft_media::AudioOut>>,
/// How many displays are connected, when the host can tell: Presenter View needs a second.
pub displays: Option<Box<dyn FnMut() -> Option<usize>>>,
}
+48
View File
@@ -378,6 +378,54 @@ fn title_bar_export_opens_the_export_dialog() {
assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export"));
}
/// A browser's Copy Image puts the picture on the clipboard with text that only names it: the
/// address (and the alt text), and an `<img>` fragment as HTML. Ctrl+V pastes the picture, as
/// PowerPoint does, instead of a text box with the link. Without a picture the link pastes as text.
#[test]
fn ctrl_v_pastes_the_picture_a_browser_copied_not_its_link() {
let url = "https://github.com/storytold/deckcraft/raw/main/assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png";
let caption = format!("{url}\nDeckCraft icon: a presentation screen with a bar chart on an orange tile");
for (text, html, with_picture, pictures_after) in [
(caption.clone(), Some(format!("<img src=\"{url}\" alt=\"DeckCraft icon\">")), true, 1),
(url.to_string(), None, true, 1),
// Firefox's text/x-moz-url is UTF-16; read as UTF-8 it carries NULs.
(url.chars().flat_map(|c| [c, '\0']).collect(), None, true, 1),
(url.to_string(), None, false, 0),
("See https://example.com for the photos".to_string(), None, true, 0),
] {
let session = Session::with_new();
let png = {
let d = session.active().unwrap();
deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0
};
let services = Services {
clipboard_image: Some(Box::new(move || with_picture.then(|| png.clone()))),
clipboard_html: Some(Box::new(move || html.clone())),
..Default::default()
};
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(session, services),
);
harness.run_steps(2);
let shapes_before = harness.state().session.active().unwrap().doc.slides[0].shapes.len();
harness.event(egui::Event::Paste(text.clone()));
harness.run_steps(2);
let d = harness.state().session.active().unwrap();
let shapes = &d.doc.slides[0].shapes;
let pictures = shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count();
assert_eq!(pictures, pictures_after, "{text:?}");
assert_eq!(shapes.len(), shapes_before + 1, "one shape, picture or text, for {text:?}");
if pictures_after == 0 {
let pasted = shapes.last().and_then(|s| s.text.as_ref()).map(deckcraft_model::TextBody::text);
assert_eq!(pasted.as_deref(), Some(text.as_str()), "the text survives");
}
}
}
/// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and
/// swallows the key press, so only the V release reaches the app.
#[test]
+2 -1
View File
@@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers.
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
username. Never hand-edit.
username. Never hand-edit. Release builds regenerate it for the release commit
(`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
+3 -2
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-03 · **Change:** trivial (run alternate language and reflection transforms survive saves; reused slide contents scale to the destination, picker thumbnails remain a gap; previous: trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-04 · **Change:** trivial (workflow-breaking regression fixed: Ctrl+C/X/V use the system clipboard again; #1414 to re-test on Windows; previous: trivial, run alternate language and reflection transforms survive saves; reused slide contents scale to the destination, picker thumbnails remain a gap; previous: trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
@@ -161,7 +161,7 @@ Detail in [file-format-parity.md](file-format-parity.md).
- **Estimate:** 3–5 h. **Beta blocker.**
### Image paste fails on Windows (#1414)
- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. Linux was not re-checked after #53 closed.
- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. From the egui 0.37 migration (#1350, v0.11.1) to 0.15.0 the desktop build left out eframe's `clipboard` feature, so egui simulated a clipboard inside the app and Ctrl+V never read the system clipboard on any platform. Fixed 2026-11-01, as is a browser's Copy Image on Linux, which pasted its link because Ctrl+V preferred any text over the picture. #1414 may have either cause: re-test on Windows.
- **Impact:** copy-paste of screenshots is a core workflow.
- **Estimate:** 2–4 h. **Beta blocker.**
@@ -371,6 +371,7 @@ Detail in [localization-parity.md](localization-parity.md).
| 2026-11-02 | trivial | Run alternate-language metadata now survives native and PPTX saves; UI and proofing gaps remain; scores unchanged |
| 2026-11-02 | trivial | PPTX reflection geometry is retained through native and PPTX resaves; no rendering parity claim |
| 2026-11-02 | trivial | Reused slide geometry and content scale to the destination; picker thumbnails remain the reuse gap |
| 2026-11-01 | trivial | Workflow-breaking regression: image paste entry: the OS clipboard was off from #1350 to 0.15.0; Linux re-checked, Windows #1414 to re-test |
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
| 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added |
| 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated |
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: picture export DPI, Retina pixel grid (#184), twist to rotate, swipe to turn the page, pen pressure; duplicated rows and headers folded; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-02 · **Change:** minor (Presenter View only with a second display on Linux and the BSDs, #1156; previous: minor, community PR wave 2026-10-14: picture export DPI, Retina pixel grid (#184), twist to rotate, swipe to turn the page, pen pressure; duplicated rows and headers folded; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
@@ -34,7 +34,7 @@ because it mostly follows from hardware use.
| Feature | PowerPoint | DeckCraft |
|---|---|---|
| Detect the projector, show on it, presenter view on the laptop | yes | **no**: the show goes full screen on the current display; presenter view opens as a normal window (egui viewport) |
| Detect the projector, show on it, presenter view on the laptop | yes | **no**: the show goes full screen on the current display; presenter view opens as a normal window (egui viewport). On Linux and the BSDs, when winit lists exactly one display, the show opens without Presenter View, as in PowerPoint; if it lists none, the setting is kept (#1156) |
| Swap displays, choose the monitor in Set Up Show | yes | no |
| Mirroring-aware behaviour | yes | no |
@@ -73,6 +73,7 @@ alignment runs once per transition (#67), and the decoders run in software.
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | On Linux and the BSDs, Presenter View stays off when winit lists exactly one display: the presenter window covered the show and stalled it. If winit lists none, the setting is kept (#1156) |
| 2026-10-14 | minor | PNG and JPEG slide export record 144 dpi at the default 2 pixels per point, so a Retina picture opens at the slide's size |
| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) |
| 2026-10-14 | trivial | Community PR wave folded: one row each for pinch/two-finger gestures and pen pressure |
+2 -2
View File
@@ -42,7 +42,7 @@ The summary between `<!-- SUMMARY -->
| Automation (✱) | 100% |
| Platforms (✱) | 67% |
Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows, #1414; Linux not re-checked), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows, #1414, re-test after the 2026-11-01 OS-clipboard fix), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
## Rows
@@ -193,7 +193,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Pictures | Picture styles and borders | P1 | D |
| Pictures | Change / reset picture | P1 | D |
| Pictures | Compress pictures | P2 | D |
| Pictures | Paste image from clipboard (fails on Windows, #1414; Linux not re-checked) | P0 | P |
| Pictures | Paste image from clipboard (fails on Windows, #1414, re-test after the 2026-11-01 OS-clipboard fix) | P0 | P |
| Pictures | Insert screenshot | P2 | M |
| Media | Insert audio (any common format) | P0 | D |
| Media | Insert video | P1 | D |
+3 -2
View File
@@ -1,6 +1,6 @@
# Slide show and presenter view: parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-02 · **Change:** minor (deleting slides removes them from custom shows; previous: minor, Set Up Slide Show ▸ Keep mouse cursor visible, #1445; previous: minor, Ctrl+wheel zooms the show around the pointer, #1286; previous: major, community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-02 · **Change:** minor (deleting slides removes them from custom shows; previous: minor, Presenter View only with a second display on Linux and the BSDs, #1156; previous: minor, Set Up Slide Show ▸ Keep mouse cursor visible, #1445; previous: minor, Ctrl+wheel zooms the show around the pointer, #1286; previous: major, community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view,
rehearsal and recording. Transition and animation playback are in
@@ -41,7 +41,7 @@ ready** (estimated), **10–16 h** to parity.
| Feature | PowerPoint | DeckCraft | Notes |
|---|---|---|---|
| Separate presenter window with current slide, next slide, notes | yes | yes (an egui viewport, 1100×700); the notes keep their bullets, numbers, indents and bold/italic, as in the notes pane | |
| Separate presenter window with current slide, next slide, notes | yes | yes (an egui viewport, 1100×700); the notes keep their bullets, numbers, indents and bold/italic, as in the notes pane | On Linux and the BSDs it stays off if winit lists exactly one display as the show starts, as in PowerPoint: on one display it covered the show, and the hidden window stalled every frame (#1156). If winit lists none, the setting is kept. There is not yet a way to switch to Presenter View by hand during a one-display show. macOS, Windows and the browser are unchanged |
| Elapsed timer | yes | yes | |
| Pause / reset timer, clock | yes | yes: Pause/Resume and Reset in presenter view (T pauses, R resets); the clock is local time, `--:--` where there is no clock | Pausing the timer does not pause the show, animations or rehearsal |
| Slide counter, previous/next/end buttons | yes | yes | |
@@ -79,6 +79,7 @@ Reading View in a window with navigation: present (checklist row done).
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | On Linux and the BSDs, Presenter View stays off when winit lists exactly one display; if it lists none, the setting is kept (#1156) |
| 2026-10-27 | minor | Set Up Slide Show ▸ Keep mouse cursor visible: full-screen shows keep a still pointer, so clickable choices stay targetable; Reading View unchanged (#1445) |
| 2026-10-22 | minor | See All Slides from presenter view opens in presenter view, not on the audience screen (#1130) |
| 2026-10-14 | trivial | Community PR wave folded: one media playback row, the presenter pen/laser row matches the laser that exists, estimates keep only the open work (~9–16 h); checklist breadth 68% |
+4 -2
View File
@@ -86,11 +86,11 @@ Messages are plain objects with a `type`:
| Message | Direction | Fields |
|---|---|---|
| `deckcraft:ready` | to the page | `version`. The app runs and listens: send the file now. |
| `deckcraft:ready` | to the page | `version`, `appVersion`. The app runs and listens: send the file now. |
| `deckcraft:open` | from the page | `name` (a bare file name, such as `Talk.pptx`), `bytes` (`ArrayBuffer` or `Uint8Array`). Opens like File › Open: a presentation (.pptx, .potx, .ppsx, .deckcraft) has that name as its file; an outline (.txt, .md, .rtf), a file sent without a name and a damaged file open untitled. |
| `deckcraft:save` | to the page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`), for every file DeckCraft would otherwise download. The page stores it. |
| `deckcraft:dirty` | to the page | `dirty`, whenever unsaved changes appear or go away. |
| `deckcraft:failed` | to the page | `error`, when the app couldn't start (no WebGPU or WebGL2). |
| `deckcraft:failed` | to the page | `error`, when the app couldn't start (no WebGPU or WebGL2) or refused a file of 2 GiB or larger. |
`saveAs` says whether the file becomes the presentation's file, as on the desktop:
@@ -134,6 +134,8 @@ beside it. The page guards leaving itself (DeckCraft adds no guard of its own),
</script>
```
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `deckcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. DeckCraft reads bytes, so a host sends `bytes`, not a `File`.
## Renderer selection and fallback flags
DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to