mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-09 11:59:36 -06:00
Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc51f46c83 | ||
|
|
0623b43032 | ||
|
|
5ee82b4e45 | ||
|
|
61423ea35b | ||
|
|
c4674f24fb | ||
|
|
4ba9a1a5d9 | ||
|
|
3504a9da42 | ||
|
|
ccab980657 | ||
|
|
48e2203123 | ||
|
|
76bd26cca0 | ||
|
|
02fbb6a976 | ||
|
|
c16db2ef3e | ||
|
|
809c44654d | ||
|
|
220b52dfdb | ||
|
|
424f575ca5 | ||
|
|
aefd54d28e | ||
|
|
25809faf1a | ||
|
|
fc35ad67c6 | ||
|
|
57780f9e61 | ||
|
|
0627fb2589 | ||
|
|
170c5591b5 | ||
|
|
a883d6b2d9 | ||
|
|
4b1e50a6b0 | ||
|
|
c7919102ac | ||
|
|
159ed07eb3 | ||
|
|
de9e79a445 | ||
|
|
d7343553b3 | ||
|
|
9a95c3a639 | ||
|
|
daeedd5d19 | ||
|
|
a2c1633fe3 | ||
|
|
bfdcc5cdfa | ||
|
|
73de9900a7 | ||
|
|
b225bb16a1 | ||
|
|
9299113268 | ||
|
|
2b6d27d525 | ||
|
|
9998ee5e07 | ||
|
|
9e8aa81298 | ||
|
|
6b5faf6d51 | ||
|
|
0b5cd0f2d6 | ||
|
|
4f171e8f78 | ||
|
|
edeb99b00e | ||
|
|
29162e66ad | ||
|
|
8435cc301f | ||
|
|
75885b49de | ||
|
|
f7b728d0c2 | ||
|
|
d82aaddab4 | ||
|
|
8f26681a1f | ||
|
|
f2356fcf63 | ||
|
|
60132037b2 | ||
|
|
ac09977c12 | ||
|
|
480cbfd4ef | ||
|
|
afc7f765c9 | ||
|
|
d9186c7ce1 | ||
|
|
75744dd67d | ||
|
|
ebd306acc1 | ||
|
|
161af3b7d5 | ||
|
|
4e4d98e69d | ||
|
|
a52b2d348f | ||
|
|
acd19ff4ac | ||
|
|
b06d60b8ed | ||
|
|
a2d9e71b5e | ||
|
|
fe35df948c | ||
|
|
ede154d6be | ||
|
|
6431460e80 | ||
|
|
af9f522c6a | ||
|
|
efd26de81d | ||
|
|
7c47d0a9ac | ||
|
|
884e3d3353 | ||
|
|
f49ff09b8a | ||
|
|
070f12cc9c | ||
|
|
46a15a4684 | ||
|
|
30ccc38e90 | ||
|
|
d6cc51e07a | ||
|
|
077cd36e9f | ||
|
|
6eaae2bd8c | ||
|
|
d5471bccf0 | ||
|
|
ba3d74dfb5 | ||
|
|
1192e5ef30 | ||
|
|
f006b5d8ce | ||
|
|
e1cfe8516e | ||
|
|
cbb0c48445 |
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 +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
+189
-47
@@ -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"
|
||||
@@ -823,12 +847,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -851,7 +875,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -859,7 +883,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -868,7 +892,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -888,7 +912,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -911,6 +935,26 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -984,7 +1028,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1006,7 +1050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -1015,7 +1059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1024,14 +1068,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -1041,14 +1085,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -1074,7 +1118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1086,7 +1130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1097,7 +1141,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1105,7 +1149,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1114,7 +1158,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1123,21 +1167,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1147,7 +1191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1165,7 +1209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1174,7 +1218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1186,15 +1230,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1212,7 +1256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1228,7 +1272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1247,7 +1291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1261,7 +1305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -1275,6 +1319,7 @@ dependencies = [
|
||||
"deckcraft-render",
|
||||
"deckcraft-text",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1286,7 +1331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1295,8 +1340,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
"eframe",
|
||||
@@ -1507,19 +1554,35 @@ 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",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1587,6 +1650,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -2708,7 +2791,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -3487,7 +3570,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4403,8 +4486,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",
|
||||
@@ -4421,6 +4504,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"
|
||||
@@ -4928,6 +5049,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",
|
||||
@@ -5370,6 +5492,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"
|
||||
@@ -5980,7 +6128,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",
|
||||
@@ -6001,7 +6149,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",
|
||||
@@ -6177,7 +6325,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6423,7 +6571,6 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6511,11 +6658,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+6
-6
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -59,11 +59,11 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0" }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -109,7 +109,7 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-04 · **Change:** trivial (Ogg Opus recovery includes trailing packets without granules in its reported duration; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: web `?host=parent` bridge opens and saves through a same-origin framing page; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **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)
|
||||
@@ -153,6 +155,8 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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 |
|
||||
@@ -162,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 |
|
||||
@@ -336,8 +343,10 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged |
|
||||
| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged |
|
||||
| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged |
|
||||
| 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 |
|
||||
|
||||
@@ -16,6 +16,10 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-ui-egui = { workspace = true }
|
||||
# The page command API (storytold/craft-libs#413): window.deckcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
# 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 = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -27,4 +31,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget", "UrlSearchParams"] }
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! `window.deckcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is DeckCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of
|
||||
//! applying a command the page was told had failed.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU8;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use deckcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
|
||||
/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes).
|
||||
struct Pending {
|
||||
reply: Receiver<Value>,
|
||||
state: Arc<AtomicU8>,
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
/// `Some` once the app has answered or gone away; `None` while the request is still waiting.
|
||||
fn poll(&mut self) -> Option<Value> {
|
||||
match self.reply.try_recv() {
|
||||
Ok(reply) => {
|
||||
self.answered = true;
|
||||
Some(reply)
|
||||
}
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.answered = true;
|
||||
Some(json!({"ok": false, "error": "the app dropped the request"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pending {
|
||||
fn drop(&mut self) {
|
||||
if !self.answered {
|
||||
ControlRequest::abandon(&self.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::install;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use deckcraft_ui_egui::{ControlRequest, SlideApp};
|
||||
use serde_json::json;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use super::{POLL_MS, Pending};
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "deckcraft";
|
||||
|
||||
/// Give the app its control queue and put `window.deckcraft` on the page. An existing
|
||||
/// `window.deckcraft` keeps the functions it already has.
|
||||
pub fn install(app: &mut SlideApp, ctx: egui::Context) {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
app.control_rx = Some(requests);
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
let mut pending = Pending { reply, state: control.state.clone(), answered: false };
|
||||
if queue.send(control).is_err() {
|
||||
pending.answered = true;
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(async move {
|
||||
loop {
|
||||
if let Some(reply) = pending.poll() {
|
||||
return reply;
|
||||
}
|
||||
sleep(POLL_MS).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `ControlRequest`'s `state` values (`deckcraft_ui_egui::control`): the app has started it, or its
|
||||
/// sender gave up.
|
||||
const TAKEN: u8 = 1;
|
||||
const ABANDONED: u8 = 2;
|
||||
|
||||
fn pending() -> (Pending, ControlRequest) {
|
||||
let (control, reply) = ControlRequest::new("document.inspect", json!({}));
|
||||
(Pending { reply, state: control.state.clone(), answered: false }, control)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() {
|
||||
let (waiting, control) = pending();
|
||||
drop(waiting);
|
||||
assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED);
|
||||
assert!(!control.take(), "the app must not run a request the page was told had failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answered_call_is_not_released() {
|
||||
let (mut waiting, control) = pending();
|
||||
assert!(control.take());
|
||||
control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
|
||||
assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1})));
|
||||
drop(waiting);
|
||||
assert_eq!(control.state.load(Ordering::SeqCst), TAKEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_the_app_never_runs_waits_until_it_is_dropped() {
|
||||
let (mut waiting, control) = pending();
|
||||
assert_eq!(waiting.poll(), None);
|
||||
drop(control);
|
||||
assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! - `?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 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
|
||||
//! outline, a file sent without a name, a repaired copy of a damaged file, a file the user
|
||||
//! picked or dropped) is a new file: `true`, as for Save As, named `<title>.deckcraft` as
|
||||
//! Save suggests on the desktop.
|
||||
//! - Save As (File › Save As as .pptx or .deckcraft, Ctrl+Shift+S): `true`. The presentation is
|
||||
//! 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` whenever unsaved changes appear or go away, `deckcraft:failed` when the
|
||||
//! app couldn't start or refused a file of 2 GiB or larger.
|
||||
//!
|
||||
//! 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::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use deckcraft_ui_egui::{Services, SlideApp};
|
||||
|
||||
/// 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 {
|
||||
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>)>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// 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;
|
||||
}
|
||||
Some(Self { host: Rc::new(host), opened: Rc::default() })
|
||||
}
|
||||
|
||||
/// 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();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Exports and Print are copies.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
|
||||
if let Err(e) = bridge.post_save(name, bytes, false) {
|
||||
log::error!("couldn't hand {name} to the page: {e}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs new comments.
|
||||
pub fn set_author(app: &mut SlideApp, author: &str) {
|
||||
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
if !author.is_empty() {
|
||||
app.session.prefs.author = author;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
app.open_from_host(name.as_deref(), &bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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());
|
||||
self.host.post_save(&name, bytes, None, save_as)
|
||||
}
|
||||
|
||||
/// Hear `deckcraft:open` and queue the file for the next frame.
|
||||
fn listen(&self, ctx: egui::Context) {
|
||||
let opened = self.opened.clone();
|
||||
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 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 = (name != NAMELESS).then_some(name);
|
||||
opened.borrow_mut().push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,22 @@
|
||||
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
|
||||
//! asynchronously through `Services::inbox`, applied to the document, slide and operation
|
||||
//! that started the pick (a dismissed picker or a closed document drops the file);
|
||||
//! - Save and Export trigger a browser download;
|
||||
//! - Save and Export trigger a browser download (or go to the framing page with `?host=parent`);
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
|
||||
//! - preferences are not persisted.
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?blank` starts with an
|
||||
//! empty presentation instead of the sample deck; `?show` starts the slide show.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! presentation to open and stores what DeckCraft saves (`host`), and `&author=` names the user
|
||||
//! for comments.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod presenter;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -251,6 +251,9 @@ impl Popup {
|
||||
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.next = previous.next.clone();
|
||||
}
|
||||
if data.slides.is_none() {
|
||||
data.slides = previous.slides.clone();
|
||||
}
|
||||
}
|
||||
*target.borrow_mut() = Some(data);
|
||||
*received.borrow_mut() = js_sys::Date::now();
|
||||
|
||||
@@ -24,8 +24,18 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// 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();
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if query().contains("webgl")
|
||||
if query_has("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
@@ -44,22 +54,43 @@ pub fn start() {
|
||||
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
|
||||
}
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let q = query();
|
||||
if !q.contains("blank") {
|
||||
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = SlideApp::new(Session::new(), services);
|
||||
// In host mode the parent sends the presentation to edit; no sample meanwhile.
|
||||
if app_bridge.is_some() {
|
||||
if let Err(e) = app.start_for_host() {
|
||||
log::error!("new presentation: {e}");
|
||||
}
|
||||
} else if !query_has("blank") {
|
||||
if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
|
||||
log::error!("sample deck: {e}");
|
||||
}
|
||||
} else if let Err(e) = app.run("file.new", serde_json::json!({})) {
|
||||
log::error!("new presentation: {e}");
|
||||
}
|
||||
if q.contains("show") {
|
||||
if query_has("show") {
|
||||
app.start_show(0, false);
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
if app_bridge.is_some()
|
||||
&& let Some(author) = query_param("author")
|
||||
{
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
}
|
||||
crate::api::install(&mut app, cc.egui_ctx.clone());
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Ready only once the runner is up: starting can still fail after the app is made.
|
||||
if let Some(bridge) = &bridge {
|
||||
match &result {
|
||||
Ok(()) => bridge.post_ready(),
|
||||
Err(e) => bridge.post_failed(&format!("{e:?}")),
|
||||
}
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -73,17 +104,36 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
/// Whether the URL has the flag `key` (`?show`, `&blank=1`). A whole parameter, never a substring:
|
||||
/// `?author=` is free text.
|
||||
fn query_has(key: &str) -> bool {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).is_ok_and(|q| q.has(key))
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inbox.
|
||||
struct WebShell {
|
||||
app: SlideApp,
|
||||
inbox: Inbox,
|
||||
/// The parent page that keeps the files (`?host=parent`).
|
||||
bridge: Option<crate::host::Bridge>,
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
/// Delegate one eframe logic pass to the wrapped application.
|
||||
/// Delegate one eframe logic pass to the wrapped application, opening the files the parent
|
||||
/// page sent first and telling it about unsaved changes after.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if let Some(bridge) = &self.bridge {
|
||||
bridge.open_files(&mut self.app);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
if let Some(bridge) = &self.bridge {
|
||||
bridge.report_dirty(&self.app);
|
||||
}
|
||||
if self.app.show.is_none()
|
||||
&& let Some(host) = self.app.services.presenter.as_mut()
|
||||
{
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -51,6 +51,9 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
// The window is closing for good: a ready update's helper starts now and installs it once
|
||||
// the process has exited.
|
||||
self.0.start_pending_update();
|
||||
// macOS (#279): end the process here, before eframe tears the window down. AccessKit's
|
||||
// macOS adapter subclasses the window's content view and restores its class on teardown;
|
||||
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
|
||||
@@ -103,7 +106,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 +135,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 +146,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 +264,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 +290,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 +362,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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -253,8 +253,15 @@ pub struct LoadedFile {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let (doc, damaged) = open_checked(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
loaded_file(path, &bytes)
|
||||
}
|
||||
|
||||
/// Parse `bytes` as the presentation file `path`, as [`read_presentation_file`] does after
|
||||
/// reading it. The web host bridge uses it for a file its page sent, which then saves back to
|
||||
/// that name like a file opened on the desktop.
|
||||
pub fn loaded_file(path: &str, bytes: &[u8]) -> Result<LoadedFile> {
|
||||
let (doc, damaged) = open_checked(path, bytes)?;
|
||||
let native = deckcraft_format::sniff(bytes) || deckcraft_pptx::sniff(bytes);
|
||||
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
|
||||
}
|
||||
|
||||
@@ -470,7 +477,10 @@ pub fn format_for_path(path: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
/// The active presentation is saved as it is now: no unsaved changes, and `path` (when given)
|
||||
/// becomes its file. `file.save` calls it after writing; the web host bridge calls it once the
|
||||
/// page has the bytes.
|
||||
pub fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
st.saved_doc = st.doc.clone();
|
||||
if path.is_some() {
|
||||
|
||||
@@ -120,13 +120,7 @@ pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = str_param(p, "align").map(|a| match a {
|
||||
"center" => Align::Center,
|
||||
"right" => Align::Right,
|
||||
"justify" => Align::Justify,
|
||||
"distributed" => Align::Distributed,
|
||||
_ => Align::Left,
|
||||
});
|
||||
let a = str_param(p, "align").map(|a| parse_align("format.paragraph", a)).transpose()?;
|
||||
let ml = f64_param(p, "indentLeft");
|
||||
let ind = f64_param(p, "indentFirst");
|
||||
let sb = f64_param(p, "spaceBefore");
|
||||
@@ -182,10 +176,10 @@ pub(super) fn clamp_pt(v: f64) -> f64 {
|
||||
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
|
||||
Ok(match s {
|
||||
"left" | "l" => Align::Left,
|
||||
"center" | "ctr" => Align::Center,
|
||||
"center" | "centre" | "ctr" => Align::Center,
|
||||
"right" | "r" => Align::Right,
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justLow" => Align::JustLow,
|
||||
"justLow" | "justifyLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
"thaiDist" => Align::ThaiDistributed,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, color_value, edit_geometry, edit_shapes, f64_param, has_selection,
|
||||
has_slide, id_param, ok, str_param, targets, usize_param, with_param, xfrm_of,
|
||||
has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param, with_param, xfrm_of,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::{
|
||||
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
|
||||
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
|
||||
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
|
||||
edit_shapes, f64_param, inherited_fills, json, opt_usize_param, requested_fill, str_param, targets,
|
||||
};
|
||||
|
||||
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||||
let i = opt_usize_param(p, "index", "shape.adjust")?.unwrap_or(0).min(16);
|
||||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||
@@ -556,7 +556,7 @@ pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>)
|
||||
}
|
||||
|
||||
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(1).min(41);
|
||||
let i = opt_usize_param(p, "index", "shape.quickStyle")?.unwrap_or(1).min(41);
|
||||
let (st, f, l) = quick_style_for(i);
|
||||
edit_shapes(s, p, "shape.quickStyle", |sh| {
|
||||
sh.style = Some(st.clone());
|
||||
|
||||
@@ -3,8 +3,9 @@ use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad
|
||||
pub(super) fn sites(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
|
||||
let st = s.doc()?;
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
|
||||
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
|
||||
// In slide space, as shape.connect uses them, for a shape inside a group too.
|
||||
let v = crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
|
||||
Ok(json!(v.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -12,8 +13,13 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("shape.connect", "missing `from` / `to`"));
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
|
||||
let (a, b) = (site_list(from), site_list(to));
|
||||
// Anywhere in the slide's tree: a shape inside a group takes a connector too (PowerPoint
|
||||
// glues to grouped shapes), its sites where the group draws them.
|
||||
let site_list = |id| {
|
||||
crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id)
|
||||
.ok_or_else(|| bad("shape.connect", format!("no shape {} on the current slide", id.0)))
|
||||
};
|
||||
let (a, b) = (site_list(from)?, site_list(to)?);
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
|
||||
}
|
||||
@@ -35,6 +41,11 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let (p0, p1) = (a[ia], b[ib]);
|
||||
let id = match id_param(p, "id", "shape.connect")? {
|
||||
// The ends are in slide space and rerouting follows the slide's own connectors, so a line
|
||||
// inside a group would be drawn in the group's space and never follow its shapes.
|
||||
Some(id) if !st.shapes().iter().any(|sh| sh.id == id) && st.shape(id).is_some_and(|sh| sh.is_line()) => {
|
||||
return Err(bad("shape.connect", "`id` names a line inside a group; ungroup it first, or leave `id` out for a new connector"));
|
||||
}
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
|
||||
|
||||
@@ -191,6 +191,13 @@ pub(super) fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
||||
edit_geometry(s, p, "shape.flip", |sh| {
|
||||
// PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design > Right to Left mirrors it),
|
||||
// also inside a group: ungrouping would hand the flip to the graphic.
|
||||
let mut graphic = false;
|
||||
deckcraft_model::walk(std::slice::from_ref(&*sh), &mut |x, _| graphic |= x.diagram.is_some());
|
||||
if graphic {
|
||||
return Err(bad("shape.flip", "Flip is not available for a SmartArt graphic; use `smartArt.layout` to change how it is laid out"));
|
||||
}
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
if v {
|
||||
x.flip_v = !x.flip_v;
|
||||
|
||||
@@ -253,9 +253,10 @@ fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>>
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
let idx = |i: usize| v.as_array().and_then(|a| a.get(i)).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
|
||||
match (idx(0), idx(1)) {
|
||||
(Some(r), Some(c)) => Ok(Some((r, c))),
|
||||
// Exactly [row, col]: an entry with more is as malformed as one with less.
|
||||
let idx = |x: &Value| x.as_u64().and_then(|x| usize::try_from(x).ok());
|
||||
match v.as_array().map(Vec::as_slice) {
|
||||
Some([r, c]) if let (Some(r), Some(c)) = (idx(r), idx(c)) => Ok(Some((r, c))),
|
||||
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
|
||||
}
|
||||
}
|
||||
@@ -389,33 +390,54 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// At most this many `cells` in one table cell command.
|
||||
const MAX_CELLS: usize = 100_000;
|
||||
|
||||
/// The cells a command acts on: `cells`, else the selected cell range, else the cell holding the
|
||||
/// caret, else every cell.
|
||||
fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize, usize)> + use<>> {
|
||||
let cells: Option<Vec<(usize, usize)>> = p
|
||||
.get("cells")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
|
||||
fn target_cells(s: &Session, p: &Value, cmd: &str) -> Result<impl Fn(&Table) -> Result<Vec<(usize, usize)>> + use<>> {
|
||||
// `cells` is a list of [row, col]: anything else, or an entry that is not one, is refused, never
|
||||
// dropped or read as absent (which would act on the selection or the whole table).
|
||||
let cells: Option<Vec<(usize, usize)>> = match p.get("cells") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) if a.len() > MAX_CELLS => {
|
||||
return Err(bad(cmd, format!("`cells` lists {} cells; at most {MAX_CELLS}", a.len())));
|
||||
}
|
||||
Some(Value::Array(a)) => Some(
|
||||
a.iter()
|
||||
.map(|c| {
|
||||
cell_param(&json!({ "cell": c }), "cell", cmd)?.ok_or_else(|| bad(cmd, format!("`cells` entry {c} is not a [row, col] cell")))
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
Some(v) => return Err(bad(cmd, format!("`cells` is a list of [row, col] cells, not {v}"))),
|
||||
};
|
||||
let st = s.doc()?;
|
||||
// A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used.
|
||||
let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false)));
|
||||
let cmd = cmd.to_string();
|
||||
Ok(move |t: &Table| match (&cells, sel) {
|
||||
(Some(c), _) => c.clone(),
|
||||
// Each listed cell must be in the table, so a command never reports success for a cell
|
||||
// it could not edit.
|
||||
(Some(c), _) => match c.iter().find(|(r, c)| *r >= t.n_rows() || *c >= t.n_cols()) {
|
||||
Some((r, c)) => Err(bad(&cmd, format!("no cell [{r}, {c}]: the table has {} rows and {} columns", t.n_rows(), t.n_cols()))),
|
||||
None => Ok(c.clone()),
|
||||
},
|
||||
(None, Some((a, b, range))) => {
|
||||
let (a, b) = if range { cell_block(t, a, b) } else { (a, b) };
|
||||
// The selection can name cells outside the table; only walk the ones that exist.
|
||||
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
|
||||
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
|
||||
Ok((a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect())
|
||||
}
|
||||
_ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(),
|
||||
_ => Ok((0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect()),
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) };
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.cellFill")?;
|
||||
with_table(s, p, "table.cellFill", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.fill.clone_from(&fill);
|
||||
}
|
||||
@@ -447,9 +469,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// "none" clears to no line, so the table style's own lines don't show through either.
|
||||
let line = if which == "none" { Line::none() } else { line };
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.borders")?;
|
||||
with_table(s, p, "table.borders", move |t| {
|
||||
let list = targets(t);
|
||||
let list = targets(t)?;
|
||||
let (Some(r0), Some(r1), Some(c0), Some(c1)) =
|
||||
(list.iter().map(|x| x.0).min(), list.iter().map(|x| x.0).max(), list.iter().map(|x| x.1).min(), list.iter().map(|x| x.1).max())
|
||||
else {
|
||||
@@ -463,9 +485,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let n = match side {
|
||||
LEFT => c.checked_sub(1).map(|c| (r, c, RIGHT)),
|
||||
RIGHT => (c + 1 < cols).then_some((r, c + 1, LEFT)),
|
||||
RIGHT => c.checked_add(1).filter(|c| *c < cols).map(|c| (r, c, LEFT)),
|
||||
TOP => r.checked_sub(1).map(|r| (r, c, BOTTOM)),
|
||||
_ => (r + 1 < rows).then_some((r + 1, c, TOP)),
|
||||
_ => r.checked_add(1).filter(|r| *r < rows).map(|r| (r, c, TOP)),
|
||||
};
|
||||
if let Some((r, c, side)) = n
|
||||
&& let Some(cell) = t.cell_mut(r, c)
|
||||
@@ -523,9 +545,9 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let side = |k: &str, i: usize| f64_param(p, k).filter(|v| v.is_finite()).map_or(base[i], |v| v.clamp(0.0, 400.0));
|
||||
let m = [side("left", 0), side("right", 1), side("top", 2), side("bottom", 3)];
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.cellMargins")?;
|
||||
with_table(s, p, "table.cellMargins", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.margins = Some(m);
|
||||
}
|
||||
@@ -560,9 +582,9 @@ fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
|
||||
}),
|
||||
};
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.cellAlign")?;
|
||||
with_table(s, p, "table.cellAlign", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
if anchor.is_some() {
|
||||
cell.anchor = anchor;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
//! Connectors glued to shapes: connection sites, snapping line ends to them, and rerouting attached
|
||||
//! connectors whenever the shapes they connect move, resize or rotate.
|
||||
|
||||
use deckcraft_geom::{Point, Xfrm};
|
||||
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
|
||||
use crate::Selection;
|
||||
use crate::cmd::xfrm_of;
|
||||
|
||||
/// Connection sites of `s` in slide coordinates (index = site index stored on connectors).
|
||||
/// Connection sites of a top-level shape `s` in slide coordinates (index = site index stored on
|
||||
/// connectors). For a shape inside a group use [`sites_of`] or [`sites_in`].
|
||||
pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
|
||||
sites_in(doc, sel, s, Affine::IDENTITY)
|
||||
}
|
||||
|
||||
/// Connection sites of `s` in slide coordinates, `to_abs` mapping the space its box lives in (the
|
||||
/// slide, or the child space of the groups around it) onto the slide.
|
||||
pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine) -> Vec<Point> {
|
||||
if s.is_line() || matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Ink { .. }) {
|
||||
return vec![];
|
||||
}
|
||||
@@ -17,10 +24,42 @@ pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
|
||||
if local.is_empty() {
|
||||
local = vec![Point::new(x.w / 2.0, 0.0), Point::new(0.0, x.h / 2.0), Point::new(x.w / 2.0, x.h), Point::new(x.w, x.h / 2.0)];
|
||||
}
|
||||
let a = x.affine();
|
||||
let a = to_abs * x.affine();
|
||||
local.into_iter().map(|p| a * p).collect()
|
||||
}
|
||||
|
||||
/// The shape `id` anywhere in `shapes` (a group's member included) with the affine that maps the
|
||||
/// space its box lives in onto the slide: a connector glues to a grouped shape, as in PowerPoint,
|
||||
/// and its sites are where the group draws them.
|
||||
pub fn locate<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId) -> Option<(&'a Shape, Affine)> {
|
||||
fn rec<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId, to_abs: Affine, depth: usize) -> Option<(&'a Shape, Affine)> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
for s in shapes {
|
||||
if s.id == id {
|
||||
return Some((s, to_abs));
|
||||
}
|
||||
if let ShapeKind::Group { children, child } = &s.kind {
|
||||
let x = xfrm_of(doc, sel, s);
|
||||
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||||
let nested = to_abs * deckcraft_geom::group_child_affine(&x, ch);
|
||||
if let Some(f) = rec(doc, sel, children, id, nested, depth + 1) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(doc, sel, shapes, id, Affine::IDENTITY, 0)
|
||||
}
|
||||
|
||||
/// Connection sites of shape `id` anywhere in `shapes`, in slide coordinates; `None` when there is
|
||||
/// no such shape.
|
||||
pub fn sites_of(doc: &Presentation, sel: &Selection, shapes: &[Shape], id: ShapeId) -> Option<Vec<Point>> {
|
||||
locate(doc, sel, shapes, id).map(|(s, a)| sites_in(doc, sel, s, a))
|
||||
}
|
||||
|
||||
/// The nearest connection site within `radius` of `p` on any visible shape except `exclude`.
|
||||
pub fn nearest_site(
|
||||
doc: &Presentation,
|
||||
@@ -68,8 +107,8 @@ pub fn reroute(doc: &mut Presentation, sel: &Selection) {
|
||||
let snapshot: Vec<Shape> = list.clone();
|
||||
let view = doc.clone();
|
||||
let site = |id: ShapeId, idx: u32| -> Option<Point> {
|
||||
let s = snapshot.iter().find(|s| s.id == id)?;
|
||||
let v = sites(&view, sel, s);
|
||||
// Anywhere in the tree: a connector glued to a shape that is then grouped keeps following it.
|
||||
let v = sites_of(&view, sel, &snapshot, id)?;
|
||||
v.get(idx as usize).or(v.first()).copied()
|
||||
};
|
||||
let mut updates: Vec<(usize, Xfrm, bool, bool)> = vec![];
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -1351,6 +1713,31 @@ fn tab_moves_between_cells_skipping_merged_ones() {
|
||||
assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_paragraph_align_names_match_format_align() {
|
||||
use deckcraft_model::text::Align;
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.align;
|
||||
for (name, want) in [
|
||||
("justLow", Align::JustLow),
|
||||
("justifyLow", Align::JustLow),
|
||||
("thaiDist", Align::ThaiDistributed),
|
||||
("ctr", Align::Center),
|
||||
("centre", Align::Center),
|
||||
("r", Align::Right),
|
||||
("just", Align::Justify),
|
||||
("dist", Align::Distributed),
|
||||
("distributed", Align::Distributed),
|
||||
("left", Align::Left),
|
||||
] {
|
||||
s.execute("format.paragraph", &json!({"align": name})).unwrap();
|
||||
assert_eq!(para(&s), Some(want), "{name}");
|
||||
}
|
||||
assert!(s.execute("format.paragraph", &json!({"align": "bogus"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_paragraphs() {
|
||||
let mut s = session();
|
||||
@@ -6509,3 +6896,176 @@ fn save_to_image_path_is_an_error_not_zip_bytes() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `shape.connect` glues to a shape inside a group, as PowerPoint does: the connector's
|
||||
/// end is set to the grouped shape and follows it when the group moves. The glue was dropped at
|
||||
/// once (the end stayed `None`) because rerouting only looked at the slide's top-level shapes,
|
||||
/// and the sites of a grouped shape were computed in the group's child space.
|
||||
#[test]
|
||||
fn shape_connect_glues_to_a_shape_inside_a_group() {
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 200, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let glue = |s: &Session, id: u64| {
|
||||
let st = s.doc().unwrap();
|
||||
match st.shape(ShapeId(id as u32)).unwrap().kind {
|
||||
ShapeKind::Connector { start, end } => (start.map(|(i, _)| i.0 as u64), end.map(|(i, _)| i.0 as u64)),
|
||||
_ => (None, None),
|
||||
}
|
||||
};
|
||||
let near = |p: deckcraft_geom::Point, x: f64, y: f64| (p.x - x).abs() < 1e-6 && (p.y - y).abs() < 1e-6;
|
||||
// A new connector into the group.
|
||||
let r = s.execute("shape.connect", &json!({"from": c, "to": a})).unwrap();
|
||||
let k = r["id"].as_u64().unwrap();
|
||||
assert_eq!(glue(&s, k), (Some(c), Some(a)), "{r}");
|
||||
let (p0, p1) = line_ends(&s, k);
|
||||
assert!(near(p0, 400.0, 35.0), "from C's left site: {p0:?}");
|
||||
assert!(near(p1, 110.0, 35.0), "to A's right site in slide space: {p1:?}");
|
||||
s.execute("shape.move", &json!({"ids": [g], "dy": 100})).unwrap();
|
||||
assert_eq!(glue(&s, k), (Some(c), Some(a)), "the glue survives the move");
|
||||
let (_, p1) = line_ends(&s, k);
|
||||
assert!(near(p1, 110.0, 135.0), "the connector follows the grouped shape: {p1:?}");
|
||||
// Re-gluing an existing line with `id`.
|
||||
let l = s.execute("shape.insert", &json!({"preset": "straightConnector1", "rect": [600, 400, 50, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("shape.connect", &json!({"id": l, "from": c, "to": b})).unwrap();
|
||||
assert_eq!(r["id"], l, "{r}");
|
||||
assert_eq!(glue(&s, l), (Some(c), Some(b)), "{r}");
|
||||
let (_, p1) = line_ends(&s, l);
|
||||
assert!(near(p1, 110.0, 325.0), "to B's right site (C is mostly to its right) in slide space, B moved with the group: {p1:?}");
|
||||
s.execute("shape.move", &json!({"ids": [g], "dx": 50})).unwrap();
|
||||
assert_eq!(glue(&s, l), (Some(c), Some(b)));
|
||||
let (_, p1) = line_ends(&s, l);
|
||||
assert!(near(p1, 160.0, 325.0), "{p1:?}");
|
||||
// shape.sites answers in slide space, as shape.connect uses them: A's right site, A moved
|
||||
// with the group.
|
||||
let v = s.execute("shape.sites", &json!({"id": a})).unwrap();
|
||||
assert!(
|
||||
v.as_array().unwrap().iter().any(|q| near(deckcraft_geom::Point::new(q[0].as_f64().unwrap(), q[1].as_f64().unwrap()), 160.0, 135.0)),
|
||||
"{v}"
|
||||
);
|
||||
// A line inside a group is refused as `id`: its ends would be written in slide space into
|
||||
// the group's space, and rerouting does not follow grouped connectors.
|
||||
let m = s.execute("shape.insert", &json!({"preset": "line", "rect": [600, 300, 50, 0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let n = s.execute("shape.insert", &json!({"preset": "rect", "rect": [600, 450, 50, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("arrange.group", &json!({"ids": [m, n]})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let e = s.execute("shape.connect", &json!({"id": m, "from": c, "to": b})).unwrap_err().to_string();
|
||||
assert!(e.contains("inside a group"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
|
||||
// A grouped shape that is not a line is refused as not a line, not as a grouped line.
|
||||
let e = s.execute("shape.connect", &json!({"id": n, "from": c, "to": b})).unwrap_err().to_string();
|
||||
assert!(e.contains("not a line"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_is_refused_on_a_smartart_graphic_and_leaves_it_a_graphic() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("insert.textBox", &json!({"text": "Goals\nRevenue"})).unwrap();
|
||||
let gid = s.execute("smartArt.fromText", &json!({"kind": "blockList"})).unwrap()["id"].as_u64().unwrap();
|
||||
for cmd in ["arrange.flipHorizontal", "arrange.flipVertical", "shape.flip"] {
|
||||
let e = s.execute(cmd, &json!({"ids": [gid]})).unwrap_err().to_string();
|
||||
assert!(e.contains("SmartArt") && e.contains("smartArt.layout"), "{cmd}: {e}");
|
||||
}
|
||||
// Grouped with another shape it cannot be flipped either: ungrouping would hand it the flip.
|
||||
let other = s.execute("insert.textBox", &json!({"text": "x"})).unwrap()["id"].as_u64().unwrap();
|
||||
let group = s.execute("arrange.group", &json!({"ids": [gid, other]})).unwrap()["id"].as_u64().unwrap();
|
||||
let e = s.execute("shape.flip", &json!({"ids": [group]})).unwrap_err().to_string();
|
||||
assert!(e.contains("SmartArt"), "{e}");
|
||||
s.execute("arrange.ungroup", &json!({"ids": [group]})).unwrap();
|
||||
// Still a SmartArt graphic: smartArt commands keep working.
|
||||
s.execute("smartArt.layout", &json!({"id": gid, "kind": "cycle"})).unwrap();
|
||||
// Ordinary shapes still flip.
|
||||
let b = s.execute("insert.textBox", &json!({"text": "x"})).unwrap();
|
||||
let id = b["id"].as_u64().unwrap();
|
||||
assert_eq!(s.execute("arrange.flipHorizontal", &json!({"ids": [id]})).unwrap(), json!(1));
|
||||
}
|
||||
|
||||
/// Table cell commands refuse a `cells` that is not a list of [row, col] cells in the table,
|
||||
/// instead of dropping the entry or reading the list as absent (which acts on the selection).
|
||||
#[test]
|
||||
fn table_cell_commands_refuse_a_malformed_cells_list() {
|
||||
let mut s = session();
|
||||
let t = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
|
||||
let rev = s.doc().unwrap().revision;
|
||||
for cells in [
|
||||
json!([[-1, 0]]),
|
||||
json!([[0]]),
|
||||
json!([[0, 0], "x"]),
|
||||
json!([[0, 0, "x"]]),
|
||||
json!([[0, 2]]),
|
||||
json!([[0, u64::MAX]]),
|
||||
json!("0,0"),
|
||||
json!({"0": 0}),
|
||||
] {
|
||||
for (cmd, extra) in [
|
||||
("table.cellFill", json!({"color": "#FF0000"})),
|
||||
("table.cellMargins", json!({"left": 4})),
|
||||
("table.cellAlign", json!({"align": "center"})),
|
||||
("table.borders", json!({"which": "right"})),
|
||||
] {
|
||||
let mut p = extra.clone();
|
||||
p["id"] = json!(t);
|
||||
p["cells"] = cells.clone();
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(cmd) && e.contains("cell"), "{cmd} {cells}: {e}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().revision, rev, "nothing changed");
|
||||
let many = json!(vec![[0, 0]; 100_001]);
|
||||
assert!(s.execute("table.cellFill", &json!({"id": t, "color": "#FF0000", "cells": many})).unwrap_err().to_string().contains("at most"));
|
||||
s.execute("table.cellFill", &json!({"id": t, "color": "#FF0000", "cells": [[1, 1]]})).unwrap();
|
||||
assert!(table_of(&s, t).cell(1, 1).unwrap().fill.is_some() && table_of(&s, t).cell(0, 0).unwrap().fill.is_none());
|
||||
}
|
||||
|
||||
/// The outline exports (PowerPoint's outline view) holds titles and body text only: footer,
|
||||
/// date and slide-number placeholders are not body lines.
|
||||
#[test]
|
||||
fn outline_export_skips_footer_date_and_slide_number() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap();
|
||||
s.execute(
|
||||
"design.headerFooter",
|
||||
&json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}),
|
||||
)
|
||||
.unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-outline-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("deck.md");
|
||||
s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "outline"})).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(text.contains("Agenda") && text.contains("First point") && text.contains("Second point"), "{text:?}");
|
||||
assert!(!text.contains("Company Confidential"), "footer text is not a body line: {text:?}");
|
||||
assert!(!text.contains("‹#›"), "the slide-number field is not a body line: {text:?}");
|
||||
assert!(!text.contains("1 May 2026"), "the date is not a body line: {text:?}");
|
||||
// The RTF outline (Save As ▸ Outline/RTF) too.
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("deck.rtf");
|
||||
s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "rtf"})).unwrap();
|
||||
let rtf = std::fs::read_to_string(&path).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}");
|
||||
assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_adjust_and_quick_style_reject_invalid_index() {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
let before = s.doc().unwrap().shape(id).unwrap().clone();
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("shape.adjust", &json!({"index": bad, "value": 0.4})).is_err(), "adjust accepted {bad}");
|
||||
assert!(s.execute("shape.quickStyle", &json!({"index": bad})).is_err(), "quickStyle accepted {bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().geom, before.geom);
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().style, before.style);
|
||||
s.execute("shape.adjust", &json!({"value": 0.4})).unwrap();
|
||||
}
|
||||
|
||||
@@ -236,7 +236,12 @@ impl Session {
|
||||
}
|
||||
}
|
||||
// A connector dragged away from shapes that stay put comes unglued at those ends.
|
||||
let moved: Vec<ShapeId> = origs2.iter().map(|o| o.0).collect();
|
||||
let mut moved: Vec<ShapeId> = vec![];
|
||||
for (id, _) in &origs2 {
|
||||
if let Some(sh) = deckcraft_model::find_shape(list, *id) {
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |s, _| moved.push(s.id));
|
||||
}
|
||||
}
|
||||
for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) {
|
||||
if let ShapeKind::Connector { start, end } = &mut sh.kind {
|
||||
for c in [start, end] {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -694,7 +694,9 @@ pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
out.push_str(&s.title().replace('\n', " "));
|
||||
out.push('\n');
|
||||
for sh in &s.shapes {
|
||||
if sh.ph_type().is_some_and(|k| k.is_title()) {
|
||||
// The title is the slide's line; footer, date and slide-number placeholders are not
|
||||
// body text (PowerPoint's outline view shows title and body placeholders only).
|
||||
if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = &sh.text {
|
||||
|
||||
@@ -71,7 +71,8 @@ pub fn slides_to_rtf(p: &Presentation) -> String {
|
||||
}
|
||||
out.push_str("\\b0\\par\n");
|
||||
for sh in &s.shapes {
|
||||
if sh.ph_type().is_some_and(|k| k.is_title()) {
|
||||
// As in the text outline: footer, date and slide-number placeholders are not body text.
|
||||
if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
|
||||
continue;
|
||||
}
|
||||
let Some(t) = &sh.text else { continue };
|
||||
|
||||
@@ -498,6 +498,11 @@ pub fn next_case(s: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the `.` at `i` sits between two ASCII digits (a decimal point or version number).
|
||||
fn is_decimal_point(all: &[char], i: usize) -> bool {
|
||||
i > 0 && all[i - 1].is_ascii_digit() && all.get(i + 1).is_some_and(|n| n.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
|
||||
pub fn change_case(s: &str, mode: &str) -> String {
|
||||
change_case_parts(&[s], mode).concat()
|
||||
@@ -532,7 +537,7 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
||||
} else {
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?')) {
|
||||
if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?') && !(c == '.' && is_decimal_point(&all, i))) {
|
||||
start = true;
|
||||
}
|
||||
}
|
||||
@@ -558,6 +563,13 @@ mod tests {
|
||||
assert_eq!(change_case("ΟΔΟΣ", "lower"), "οδος");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentence_case_decimal_point_is_not_a_boundary() {
|
||||
assert_eq!(change_case("I HAVE 3.5 APPLES", "sentence"), "I have 3.5 apples");
|
||||
assert_eq!(change_case_parts(&["V 1.", "2 BETA"], "sentence").concat(), "V 1.2 beta");
|
||||
assert_eq!(change_case("HELLO. WORLD", "sentence"), "Hello. World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_sigma_across_runs_is_not_final() {
|
||||
assert_eq!(change_case_parts(&["ΟΔΟΣ", "ΑΣ"], "title"), ["Οδοσ", "ας"]);
|
||||
|
||||
@@ -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);
|
||||
@@ -252,6 +314,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
|
||||
compat_ln_spc: None,
|
||||
vert_overflow: None,
|
||||
horz_overflow: None,
|
||||
raw3d: None,
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
@@ -124,6 +124,9 @@ pub struct BodyProps {
|
||||
pub compat_ln_spc: Option<bool>,
|
||||
pub vert_overflow: Option<TextOverflow>,
|
||||
pub horz_overflow: Option<TextOverflow>,
|
||||
/// Unmodelled 3-D XML on the text body (`a:scene3d`, `a:sp3d`, `a:flatTx`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw3d: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -187,6 +190,9 @@ impl BodyProps {
|
||||
self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc);
|
||||
self.vert_overflow = self.vert_overflow.or(p.vert_overflow);
|
||||
self.horz_overflow = self.horz_overflow.or(p.horz_overflow);
|
||||
if self.raw3d.is_none() {
|
||||
self.raw3d.clone_from(&p.raw3d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -337,16 +337,31 @@ fn fit_moving_avg(pairs: &[(f64, f64)], period: usize) -> Option<(Model, Vec<f64
|
||||
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option<Vec<f64>> {
|
||||
// Fit `y - b0 = (x / sx) * q(u)` with `u = (x - xm) / su` and `q` of degree `order - 1`. The
|
||||
// basis `(x / sx) * u^j` spans the same polynomials as `x^(j + 1)`, but keeps the pivots in
|
||||
// `solve` independent of the units and the offset of x.
|
||||
let n = xs.len() as f64;
|
||||
let sx = xs.iter().fold(0.0_f64, |acc, x| acc.max(x.abs()));
|
||||
if sx <= 0.0 || !sx.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let xm = xs.iter().sum::<f64>() / n;
|
||||
let su = match xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs())) {
|
||||
s if s > 0.0 && s.is_finite() => s,
|
||||
_ => 1.0,
|
||||
};
|
||||
let m = order;
|
||||
let mut ata = vec![vec![0.0; m]; m];
|
||||
let mut atb = vec![0.0; m];
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
let v = x / sx;
|
||||
let u = (x - xm) / su;
|
||||
let dy = y - b0;
|
||||
for i in 0..m {
|
||||
let xi = x.powi((i + 1) as i32);
|
||||
let xi = v * u.powi(i as i32);
|
||||
atb[i] += dy * xi;
|
||||
for j in i..m {
|
||||
ata[i][j] += xi * x.powi((j + 1) as i32);
|
||||
ata[i][j] += xi * v * u.powi(j as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -355,10 +370,11 @@ fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Opti
|
||||
ata[i][j] = ata[j][i];
|
||||
}
|
||||
}
|
||||
let solved = solve(ata, atb)?;
|
||||
let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / su.powi(k as i32)).collect();
|
||||
let q = expand_shift(¢red, xm);
|
||||
let mut coeff = Vec::with_capacity(order + 1);
|
||||
coeff.push(b0);
|
||||
coeff.extend(solved);
|
||||
coeff.extend(q.iter().map(|c| c / sx));
|
||||
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
||||
}
|
||||
|
||||
@@ -374,7 +390,13 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
|
||||
return polyfit_with_intercept(xs, ys, order, b0);
|
||||
}
|
||||
let xm = xs.iter().sum::<f64>() / n as f64;
|
||||
let us: Vec<f64> = xs.iter().map(|x| x - xm).collect();
|
||||
// Scale the centred x to unit range so the pivot threshold in `solve` does not depend on the
|
||||
// units of x.
|
||||
let s = xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs()));
|
||||
if s <= 0.0 || !s.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let us: Vec<f64> = xs.iter().map(|x| (x - xm) / s).collect();
|
||||
let m = order + 1;
|
||||
let mut ata = vec![vec![0.0; m]; m];
|
||||
let mut atb = vec![0.0; m];
|
||||
@@ -393,7 +415,7 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
|
||||
ata[i][j] = ata[j][i];
|
||||
}
|
||||
}
|
||||
let centred = solve(ata, atb)?;
|
||||
let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / s.powi(k as i32)).collect();
|
||||
let coeff = expand_shift(¢red, xm);
|
||||
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
||||
}
|
||||
@@ -571,6 +593,49 @@ mod tests {
|
||||
assert!((fit.y(3e-10).unwrap() - 3.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poly_fits_tiny_x_values() {
|
||||
let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5];
|
||||
let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x;
|
||||
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
|
||||
let fit = line(TrendlineType::Poly).fit(&xs, &ys).expect("poly fit of tiny x values");
|
||||
assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9);
|
||||
let big: Vec<f64> = xs.iter().map(|x| x * 1e5).collect();
|
||||
let scaled = line(TrendlineType::Poly).fit(&big, &ys).expect("poly fit of scaled x values");
|
||||
for x in xs {
|
||||
assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poly_with_intercept_fits_tiny_x_values() {
|
||||
let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5];
|
||||
let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x;
|
||||
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
|
||||
let mut poly = line(TrendlineType::Poly);
|
||||
poly.intercept = Some(1.0);
|
||||
let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of tiny x values");
|
||||
assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9);
|
||||
let big: Vec<f64> = xs.iter().map(|x| x * 1e5).collect();
|
||||
let scaled = poly.fit(&big, &ys).expect("poly fit with intercept of scaled x values");
|
||||
for x in xs {
|
||||
assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poly_with_intercept_fits_offset_but_narrow_x_range() {
|
||||
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
|
||||
let f = |x: f64| 1.0 + x * (1.0 + 100.0 * (x - 45000.02));
|
||||
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
|
||||
let mut poly = line(TrendlineType::Poly);
|
||||
poly.intercept = Some(1.0);
|
||||
let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of offset x values");
|
||||
for x in xs {
|
||||
assert!((fit.y(x).unwrap() - f(x)).abs() < 1e-2, "{x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_fits_offset_but_narrow_x_range() {
|
||||
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
|
||||
|
||||
@@ -629,7 +629,19 @@ pub fn list_style(imp: &mut Imp, part: &Part, ls: &El) -> ListStyle {
|
||||
}
|
||||
|
||||
pub fn text_body(imp: &mut Imp, part: &Part, tx: &El) -> TextBody {
|
||||
let body = tx.child("bodyPr").map(body_pr).unwrap_or_default();
|
||||
let mut body = tx.child("bodyPr").map(body_pr).unwrap_or_default();
|
||||
if let Some(b) = tx.child("bodyPr") {
|
||||
let mut raw3d = String::new();
|
||||
if let Some(sc) = b.child("scene3d") {
|
||||
raw3d.push_str(&part.keep_inner(sc));
|
||||
}
|
||||
if let Some(sp) = b.child("sp3d").or_else(|| b.child("flatTx")) {
|
||||
raw3d.push_str(&part.keep_inner(sp));
|
||||
}
|
||||
if !raw3d.is_empty() && raw3d.len() <= 64 * 1024 {
|
||||
body.raw3d = Some(raw3d);
|
||||
}
|
||||
}
|
||||
let list_style = tx.child("lstStyle").map(|l| list_style(imp, part, l)).unwrap_or_default();
|
||||
let paragraphs = tx.children_named("p").take(100_000).map(|p| paragraph(imp, part, p)).collect();
|
||||
TextBody { body, list_style, paragraphs }
|
||||
|
||||
@@ -395,6 +395,12 @@ fn grp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> Sha
|
||||
if let Some(fx) = pr.child("effectLst") {
|
||||
s.effects = Some(dml::effects(fx));
|
||||
}
|
||||
if let Some(sc) = pr.child("scene3d") {
|
||||
let raw = part.keep_inner(sc);
|
||||
if !raw.is_empty() && raw.len() <= 64 * 1024 {
|
||||
s.effects.get_or_insert_with(Effects::default).raw3d = Some(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
let children = sp_tree(imp, part, ctx, e, depth + 1);
|
||||
let child = child.or_else(|| s.xfrm.map(|x| Xfrm::new(x.x, x.y, x.w, x.h))).unwrap_or_default();
|
||||
|
||||
@@ -461,6 +461,21 @@ fn sound_label(name: &str) -> String {
|
||||
if label.is_empty() { "sound.wav".into() } else { label }
|
||||
}
|
||||
|
||||
/// Validate an unmodelled 3-D fragment (`a:scene3d`, `a:sp3d`, `a:flatTx`) before writing raw XML.
|
||||
pub fn valid_raw3d<'a>(raw: &'a str, allowed: &[&str]) -> Option<&'a str> {
|
||||
if raw.is_empty() || raw.len() > 64 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let wrap = format!("<r xmlns:a=\"{}\">{raw}</r>", crate::opc::NS_A);
|
||||
let doc = crate::xml::parse(wrap.as_bytes()).ok()?;
|
||||
let els: Vec<_> = doc.root.elements().collect();
|
||||
(!els.is_empty()
|
||||
&& els.len() <= allowed.len()
|
||||
&& doc.root.children.len() == els.len()
|
||||
&& els.iter().all(|e| e.prefix() == "a" && allowed.contains(&e.local()) && !crate::read::has_rel_refs(e)))
|
||||
.then_some(raw)
|
||||
}
|
||||
|
||||
pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
let a = A::new()
|
||||
.o("rot", b.rot.map(|r| (r * 60_000.0).round().clamp(-21_600_000.0, 21_600_000.0) as i64))
|
||||
@@ -481,7 +496,8 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
.o("vertOverflow", b.vert_overflow.map(|v| v.xml()))
|
||||
.o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(b.horz_overflow));
|
||||
let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
if warp.is_none() && b.autofit.is_none() {
|
||||
let raw3d = b.raw3d.as_deref().and_then(|s| valid_raw3d(s, &["scene3d", "sp3d", "flatTx"]));
|
||||
if warp.is_none() && b.autofit.is_none() && raw3d.is_none() {
|
||||
w.empty("a:bodyPr", a);
|
||||
return;
|
||||
}
|
||||
@@ -515,6 +531,9 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(raw) = raw3d {
|
||||
w.raw(raw);
|
||||
}
|
||||
w.close("a:bodyPr");
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +334,20 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
|
||||
dml::fill(w, x, o, f);
|
||||
}
|
||||
if let Some(fx) = &s.effects {
|
||||
dml::effects(w, fx);
|
||||
if fx.fill_overlay.is_some()
|
||||
|| fx.glow.is_some()
|
||||
|| fx.inner_shadow.is_some()
|
||||
|| fx.outer_shadow.is_some()
|
||||
|| fx.reflection.is_some()
|
||||
|| fx.soft_edge.is_some()
|
||||
{
|
||||
dml::effects(w, fx);
|
||||
}
|
||||
if let Some(raw) =
|
||||
fx.raw3d.as_deref().map(|s| s.find("<a:sp3d").map_or(s, |i| &s[..i])).and_then(|s| dml::valid_raw3d(s, &["scene3d"]))
|
||||
{
|
||||
w.raw(raw);
|
||||
}
|
||||
}
|
||||
w.close("p:grpSpPr");
|
||||
shapes(w, x, o, cx, children, depth + 1);
|
||||
|
||||
@@ -241,6 +241,67 @@ fn theme_and_bevel_survive(once: &Presentation, twice: &Presentation) {
|
||||
assert_eq!(by_name(twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_group_3d_survive() {
|
||||
let text_3d =
|
||||
"<a:scene3d><a:camera prst=\"orthographicFront\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:sp3d extrusionH=\"12700\"/>";
|
||||
let flat_tx = "<a:scene3d><a:camera prst=\"orthographicFront\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:flatTx z=\"0\"/>";
|
||||
let mut p3 = defaults::new_presentation(None);
|
||||
let mut sh = shape(&mut p3, "Text3D", 10.0, 10.0, 100.0, 40.0);
|
||||
sh.text = Some(TextBody {
|
||||
body: BodyProps { raw3d: Some(text_3d.into()), ..Default::default() },
|
||||
paragraphs: vec![Paragraph::new("3D Text")],
|
||||
..Default::default()
|
||||
});
|
||||
let mut sh_flat = shape(&mut p3, "FlatTx3D", 10.0, 55.0, 100.0, 40.0);
|
||||
sh_flat.text = Some(TextBody {
|
||||
body: BodyProps { raw3d: Some(flat_tx.into()), ..Default::default() },
|
||||
paragraphs: vec![Paragraph::new("Flat Text")],
|
||||
..Default::default()
|
||||
});
|
||||
let grp_3d = "<a:scene3d><a:camera prst=\"isometricTopUp\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d>";
|
||||
let mut grp = shape(&mut p3, "Group3D", 10.0, 60.0, 100.0, 80.0);
|
||||
grp.effects = Some(Effects { raw3d: Some(grp_3d.into()), ..Default::default() });
|
||||
grp.kind = ShapeKind::Group { children: vec![sh, sh_flat], child: Xfrm::new(10.0, 10.0, 100.0, 80.0) };
|
||||
let layout = defaults::layout_of_kind(&p3, deckcraft_model::LayoutType::Blank).unwrap_or_default();
|
||||
let mut s3 = defaults::new_slide(&mut p3, layout).expect("slide");
|
||||
s3.shapes.push(grp);
|
||||
p3.slides.push(Arc::new(s3));
|
||||
|
||||
let bytes = deckcraft_pptx::export(&p3).expect("export");
|
||||
let slide_xml = {
|
||||
use std::io::Read;
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
|
||||
let mut s = String::new();
|
||||
z.by_name("ppt/slides/slide2.xml").expect("slide2").read_to_string(&mut s).expect("utf8");
|
||||
s
|
||||
};
|
||||
assert!(!slide_xml.contains("<a:effectLst/>"), "Group3D with only raw3d must not emit empty effectLst: {slide_xml}");
|
||||
assert!(slide_xml.contains("<a:flatTx z=\"0\"/>"), "{slide_xml}");
|
||||
|
||||
let r3 = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let r3_twice = round(&r3);
|
||||
let g3 = by_name(&r3, "Group3D");
|
||||
let g3_raw = g3.effects.as_ref().and_then(|e| e.raw3d.as_deref()).unwrap_or("");
|
||||
let t3_raw = g3.children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()).unwrap_or("");
|
||||
let flat_raw = g3.children()[1].text.as_ref().and_then(|t| t.body.raw3d.as_deref()).unwrap_or("");
|
||||
assert!(g3_raw.contains("isometricTopUp"), "{g3_raw}");
|
||||
assert!(t3_raw.contains("orthographicFront") && t3_raw.contains("extrusionH=\"12700\""), "{t3_raw}");
|
||||
assert!(flat_raw.contains("flatTx"), "{flat_raw}");
|
||||
assert_eq!(by_name(&r3_twice, "Group3D").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(g3_raw));
|
||||
assert_eq!(by_name(&r3_twice, "Group3D").children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()), Some(t3_raw));
|
||||
assert_eq!(by_name(&r3_twice, "Group3D").children()[1].text.as_ref().and_then(|t| t.body.raw3d.as_deref()), Some(flat_raw));
|
||||
|
||||
// BodyProps::inherit propagates parent raw3d only when child raw3d is None.
|
||||
let parent_body = BodyProps { raw3d: Some(text_3d.into()), ..Default::default() };
|
||||
let mut child_body = BodyProps::default();
|
||||
child_body.inherit(&parent_body);
|
||||
assert_eq!(child_body.raw3d.as_deref(), Some(text_3d));
|
||||
let mut override_body = BodyProps { raw3d: Some(flat_tx.into()), ..Default::default() };
|
||||
override_body.inherit(&parent_body);
|
||||
assert_eq!(override_body.raw3d.as_deref(), Some(flat_tx));
|
||||
}
|
||||
|
||||
fn connector_glue_survives(p: &Presentation, once: &Presentation, twice: &Presentation) {
|
||||
let c = by_name(once, "Connector");
|
||||
assert!(matches!(c.kind, ShapeKind::Connector { start: Some((s, 3)), end: Some((e, 1)) } if s == left_of(p) && e == right_of(p)), "{:?}", c.kind);
|
||||
|
||||
@@ -19,6 +19,7 @@ deckcraft-anim = { workspace = true }
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-media = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { workspace = true }
|
||||
craft-ui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -106,6 +106,7 @@ impl SlideApp {
|
||||
twisting: false,
|
||||
next_file_id: 1,
|
||||
pending_files: Vec::new(),
|
||||
host_start: None,
|
||||
ime_tracker: egui::ime_context::ImeContextTracker::new(),
|
||||
ime_pending: None,
|
||||
ime_frame_viewport: None,
|
||||
@@ -136,6 +137,14 @@ impl SlideApp {
|
||||
updates_ui::enable(self);
|
||||
}
|
||||
|
||||
/// Start the helper of an update that is ready, so that it installs once the process has
|
||||
/// exited. The desktop shell calls it in `on_exit`, which ends the process on macOS before the
|
||||
/// app is dropped. Later calls do nothing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn start_pending_update(&mut self) {
|
||||
updates_ui::start_pending_update(self);
|
||||
}
|
||||
|
||||
/// A message that must be seen wherever the user is: a toast, which also shows over the start
|
||||
/// screen and the File tab, where there is no status bar.
|
||||
pub fn notify(&mut self, msg: impl Into<String>) {
|
||||
|
||||
@@ -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(" ", " ")
|
||||
}
|
||||
|
||||
impl SlideApp {
|
||||
/// Offer the copy or cut on the system clipboard.
|
||||
///
|
||||
|
||||
@@ -20,6 +20,11 @@ impl SlideApp {
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = if UI_COMMANDS.iter().any(|c| c.0 == id) {
|
||||
self.run_ui(id, ¶ms)
|
||||
} else if id == "file.save" && self.services.host_save.is_some() && params.get("path").is_none() {
|
||||
// A page that keeps the files (`?host=parent`): Save by id (the command palette) goes
|
||||
// to the page like Ctrl+S. The engine would write to the presentation's path, which a
|
||||
// browser can't.
|
||||
self.save_to_host(None).map(|name| json!({"path": name}))
|
||||
} else {
|
||||
self.session.execute(id, ¶ms).map_err(|e| e.to_string())
|
||||
};
|
||||
|
||||
@@ -462,6 +462,8 @@ impl SlideApp {
|
||||
Err(e) => self.set_status(e.to_string()),
|
||||
}
|
||||
}
|
||||
} else if self.services.host_save.is_some() {
|
||||
let _ = self.save_to_host(Some(ext));
|
||||
} else if let Some(dl) = self.services.download.as_mut()
|
||||
&& let Some(d) = self.session.active()
|
||||
{
|
||||
@@ -487,6 +489,10 @@ impl SlideApp {
|
||||
}
|
||||
|
||||
pub fn save(&mut self) {
|
||||
if self.services.host_save.is_some() {
|
||||
let _ = self.save_to_host(None);
|
||||
return;
|
||||
}
|
||||
if self.session.active().and_then(|d| d.path.clone()).is_some() {
|
||||
match self.session.execute("file.save", &json!({})) {
|
||||
Ok(_) => self.set_status("Saved"),
|
||||
@@ -497,6 +503,85 @@ impl SlideApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves to the page that keeps the files ([`Services::host_save`](crate::Services::host_save)).
|
||||
/// Save (`ext` `None`) writes the presentation's own file (the one the page sent, or the
|
||||
/// last one saved there) in that file's format, as the desktop app does; a presentation with
|
||||
/// no file of its own is a new `.deckcraft` file, the name Save suggests on the desktop. Save
|
||||
/// As (`pptx`, `deckcraft`) is a new file named after the presentation. Once the page has the
|
||||
/// bytes, the presentation is saved and that name is its file. Returns the name, or why it
|
||||
/// wasn't saved (also shown in the status bar).
|
||||
pub(super) fn save_to_host(&mut self, ext: Option<&str>) -> Result<String, String> {
|
||||
use deckcraft_engine::cmd::file::{format_for_path, mark_saved, save_bytes};
|
||||
let Some(d) = self.session.active() else { return Err("no presentation is open".into()) };
|
||||
let own = d.path.as_deref().and_then(|p| std::path::Path::new(p).file_name()).map(|n| n.to_string_lossy().to_string());
|
||||
let (name, save_as) = match (ext, own) {
|
||||
(None, Some(own)) => (own, false),
|
||||
(ext, _) => (format!("{}.{}", d.title(), ext.unwrap_or("deckcraft")), true),
|
||||
};
|
||||
let saved = save_bytes(&d.doc, format_for_path(&name)).map_err(|e| e.to_string()).and_then(|bytes| {
|
||||
let host = self.services.host_save.as_mut().ok_or("no page to save to")?;
|
||||
host(&name, &bytes, save_as).map_err(|e| format!("Couldn't save {name}: {e}"))?;
|
||||
mark_saved(&mut self.session, Some(name.clone())).map_err(|e| e.to_string())
|
||||
});
|
||||
match saved {
|
||||
Ok(()) => {
|
||||
self.set_status(format!("Saved {name}"));
|
||||
Ok(name)
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_status(e.clone());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file the page that keeps the files sent (`?host=parent`), with the name it sent, if any.
|
||||
/// It opens as File › Open opens it on the desktop: a presentation as itself, an outline
|
||||
/// (.txt, .md, .rtf) as a new untitled presentation. The name is the presentation's file only
|
||||
/// when the page sent one with a presentation extension and the file is an intact
|
||||
/// presentation, so Save hands it back under that name and format; anything else (no name, an
|
||||
/// outline, a damaged file opened as a repaired copy) is untitled, and its first Save is a new
|
||||
/// file. The first presentation that opens takes the place of the empty one host mode started
|
||||
/// with ([`Self::start_for_host`]), if that one is still open, untitled and unchanged; later
|
||||
/// files open beside the others.
|
||||
pub fn open_from_host(&mut self, name: Option<&str>, bytes: &[u8]) {
|
||||
let blank = self.host_start.filter(|&uid| self.session.documents().iter().any(|d| d.uid == uid && d.path.is_none() && !d.is_dirty()));
|
||||
let before = self.session.documents().len();
|
||||
let shown = name.unwrap_or("Presentation");
|
||||
let named = name.is_some_and(|n| is_presentation_name(&n.to_ascii_lowercase()));
|
||||
let opened = deckcraft_engine::cmd::file::loaded_file(shown, bytes).and_then(|mut f| {
|
||||
// `file.adopt` keeps the path only for a native file; a name the page didn't send
|
||||
// for a presentation must not become one.
|
||||
f.native &= named;
|
||||
let token = self.session.stage_loaded(f);
|
||||
self.session.execute("file.adopt", &json!({"path": shown, "token": token}))
|
||||
});
|
||||
if let Err(e) = opened {
|
||||
self.notify(e.to_string());
|
||||
}
|
||||
self.drain_requests();
|
||||
if self.session.documents().len() <= before {
|
||||
return;
|
||||
}
|
||||
self.host_start = None;
|
||||
if let Some(opened) = self.session.active().map(|d| d.uid)
|
||||
&& let Some(i) = self.session.documents().iter().position(|d| Some(d.uid) == blank)
|
||||
{
|
||||
self.session.close_document(i);
|
||||
if let Some(i) = self.session.documents().iter().position(|d| d.uid == opened) {
|
||||
self.session.set_active(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Host mode (`?host=parent`) starts with an empty presentation instead of the sample, for
|
||||
/// the first file the page sends to take its place ([`Self::open_from_host`]).
|
||||
pub fn start_for_host(&mut self) -> Result<(), String> {
|
||||
self.run("file.new", json!({}))?;
|
||||
self.host_start = self.session.active().map(|d| d.uid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Act on requests commands left for the UI.
|
||||
pub(super) fn drain_requests(&mut self) {
|
||||
for r in std::mem::take(&mut self.session.ui_requests) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
@@ -144,6 +153,10 @@ impl SlideApp {
|
||||
let visible = show::viewport_visible(ctx);
|
||||
let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return };
|
||||
let Some(show) = self.show.as_mut() else { return };
|
||||
if show.presenter_grid && self.services.presenter.as_ref().is_some_and(|host| !host.is_open()) {
|
||||
// A web popup closed with its grid open must not hold auto-advance while this tab is hidden (#1866).
|
||||
show.presenter_grid = false;
|
||||
}
|
||||
if visible {
|
||||
// A visible logic-only pass (`run_logic`) carries the last full frame's input time.
|
||||
// Anchoring that stale time to `now` would move the hidden clock backwards if the
|
||||
|
||||
@@ -249,7 +249,8 @@ fn act(app: &mut SlideApp, nav: Nav, current: Page) -> Option<Page> {
|
||||
match nav {
|
||||
Nav::Page(p) => Some(p),
|
||||
Nav::Save => {
|
||||
let web = app.services.pick_save.is_none() && app.services.download.is_some();
|
||||
// A page that keeps the files (`host_save`) saves as the desktop does.
|
||||
let web = app.services.pick_save.is_none() && app.services.download.is_some() && app.services.host_save.is_none();
|
||||
let has_path = app.session.active().is_some_and(|d| d.path.is_some());
|
||||
if !has_path && !web {
|
||||
// A presentation that was never saved goes to Save As first, as in PowerPoint.
|
||||
|
||||
@@ -166,15 +166,28 @@ pub(super) fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out:
|
||||
})
|
||||
.response
|
||||
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type"));
|
||||
let web = app.services.pick_save.is_none();
|
||||
// A page that keeps the files (`host_save`) saves; plain web downloads.
|
||||
let web = app.services.pick_save.is_none() && app.services.host_save.is_none();
|
||||
let label = if web { "Download" } else { "Save" };
|
||||
let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0)));
|
||||
if save.on_hover_text(if web { "Download the file" } else { "Choose where to save" }).clicked() {
|
||||
if save
|
||||
.on_hover_text(if web {
|
||||
"Download the file"
|
||||
} else if app.services.host_save.is_some() {
|
||||
"Save to the page"
|
||||
} else {
|
||||
"Choose where to save"
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
let fmt = bs.file_type;
|
||||
let done = if matches!(fmt, "pptx" | "deckcraft") {
|
||||
let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
|
||||
// A save bumps the revision, also one under the same name that leaves the path
|
||||
// and the dirty flag as they were; a cancelled dialog or a failed save doesn't.
|
||||
let snapshot = |app: &SlideApp| app.session.active().map(|d| (d.path.clone(), d.is_dirty(), d.revision));
|
||||
let before = snapshot(app);
|
||||
app.save_as_dialog(fmt);
|
||||
web || app.session.active().map(|d| (d.path.clone(), d.is_dirty())) != before
|
||||
web || snapshot(app) != before
|
||||
} else {
|
||||
app.export_as(fmt, json!({}))
|
||||
};
|
||||
|
||||
@@ -207,7 +207,8 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_
|
||||
let mut budget = 4u32;
|
||||
let mut draw_slide = |ui: &mut Ui, i: usize, r: Rect| {
|
||||
let px = (r.width() * ui.ctx().pixels_per_point()).round().clamp(16.0, 1600.0) as u32;
|
||||
match app.textures.thumb(ui.ctx(), doc, i, px, &mut budget) {
|
||||
// Print preview matches the actual exported PDF output (always full color).
|
||||
match app.textures.thumb(ui.ctx(), doc, i, px, false, &mut budget) {
|
||||
Some(tex) => {
|
||||
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
|
||||
@@ -393,6 +393,64 @@ fn ctrl_w_asks_before_closing_unsaved_changes() {
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
|
||||
}
|
||||
|
||||
/// Framed by a page that keeps the files (`?host=parent`), the Close prompt's Save is a real save:
|
||||
/// when the page doesn't get the file the presentation stays open with its changes, and when it
|
||||
/// does the presentation closes. (A plain browser download closes either way.)
|
||||
#[test]
|
||||
fn host_close_prompt_keeps_the_presentation_when_the_save_fails() {
|
||||
for fail in [true, false] {
|
||||
let services = Services {
|
||||
host_save: Some(Box::new(move |_: &str, _: &[u8], _: bool| if fail { Err("the page is gone".into()) } else { Ok(()) })),
|
||||
download: Some(Box::new(|_: &str, _: &[u8]| {})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut h = harness(SlideApp::new(Session::with_new(), services));
|
||||
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
|
||||
// Enter is the prompt's Save.
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none(), "fail={fail}");
|
||||
let open = h.state().session.documents().len();
|
||||
assert_eq!(open, usize::from(fail), "fail={fail}: open presentations");
|
||||
if fail {
|
||||
assert!(h.state().session.active().unwrap().is_dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Framed by a page that keeps the files, Save As › Save under the presentation's own name goes
|
||||
/// back to the slide once the page has the file. The bug this prevents: that save leaves the
|
||||
/// file and the dirty flag as they were, so the Save As page stayed open and a second click sent
|
||||
/// the file again.
|
||||
#[test]
|
||||
fn host_save_as_under_the_same_name_returns_to_the_slide() {
|
||||
let got: Rc<RefCell<Vec<(String, bool)>>> = Default::default();
|
||||
let saves = got.clone();
|
||||
let services = Services {
|
||||
host_save: Some(Box::new(move |name: &str, _: &[u8], save_as: bool| {
|
||||
saves.borrow_mut().push((name.to_string(), save_as));
|
||||
Ok(())
|
||||
})),
|
||||
download: Some(Box::new(|_: &str, _: &[u8]| {})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SlideApp::new(Session::new(), services);
|
||||
app.start_for_host().unwrap();
|
||||
let deck = deckcraft_engine::cmd::file::save_bytes(&Session::with_new().doc().unwrap().doc, "pptx").unwrap();
|
||||
app.open_from_host(Some("deck.pptx"), &deck);
|
||||
let mut h = harness(app);
|
||||
h.state_mut().run("app.backstage", json!({"page": "saveAs"})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_on_page(&h, "Save");
|
||||
h.run_steps(3);
|
||||
assert_eq!(*got.borrow(), [("deck.pptx".to_string(), true)]);
|
||||
assert!(h.state().backstage.is_none(), "the save returns to the slide");
|
||||
}
|
||||
|
||||
/// Info shows the presentation's counts and properties, and edits them through file.properties.
|
||||
#[test]
|
||||
fn info_shows_and_edits_the_properties() {
|
||||
|
||||
@@ -299,7 +299,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
vec2(w as f32 * scale, h as f32 * scale),
|
||||
);
|
||||
let mut budget = 2;
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, &mut budget) {
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, app.ui.grayscale, &mut budget) {
|
||||
painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{Color32, CornerRadius, Rect, Sense, SlideApp, Stroke, Tokens, Ui, pos2, theme, vec2};
|
||||
|
||||
/// Room the About credits need for their table; the About tab keeps its compact size.
|
||||
pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(640.0, 380.0);
|
||||
pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(820.0, 380.0);
|
||||
|
||||
/// The Contributors / Models body in a block of its own fixed size, below the tab row. Sizing the
|
||||
/// dialog's `ui` instead (`set_min_size` / `set_max_height`) squeezed the tab row and the first
|
||||
|
||||
@@ -339,8 +339,9 @@ fn dlg_close(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool {
|
||||
let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)));
|
||||
if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
app.save();
|
||||
// In the browser a save is a download: the presentation stays "unsaved".
|
||||
let web = app.services.pick_save.is_none() && app.services.download.is_some();
|
||||
// In the browser a save is a download: the presentation stays "unsaved". A page
|
||||
// that keeps the files saves it for real, so a failed save keeps it open.
|
||||
let web = app.services.pick_save.is_none() && app.services.download.is_some() && app.services.host_save.is_none();
|
||||
if web || app.session.active().is_some_and(|d| !d.is_dirty()) {
|
||||
run(app, "file.close", json!({}));
|
||||
}
|
||||
|
||||
@@ -246,12 +246,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
|
||||
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
|
||||
ui.separator();
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
|
||||
// once its columns are dragged wider than the dialog.
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
|
||||
} else {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
if i > 0 {
|
||||
@@ -260,39 +262,153 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
/// How the contributor table's spare width is shared out: all of it to Name.
|
||||
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// How the model table's spare width is shared out: to Company and Model, the text columns.
|
||||
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// The narrowest a column can be dragged.
|
||||
const MIN_COLUMN: f32 = 28.0;
|
||||
|
||||
/// The width a column needs for its header and its widest cell.
|
||||
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
|
||||
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
|
||||
widest + 2.0 * ui.spacing().button_padding.x
|
||||
}
|
||||
|
||||
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
|
||||
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
|
||||
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
|
||||
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
|
||||
natural
|
||||
.iter()
|
||||
.zip(grow)
|
||||
.map(|(w, g)| {
|
||||
if *g > 0.0 && spare >= 0.0 {
|
||||
w + spare * g / weight
|
||||
} else if *g > 0.0 && flexible > 0.0 {
|
||||
w - (-spare).min(flexible) * slack(*w) / flexible
|
||||
} else {
|
||||
*w
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for c in list {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.label(group(c.prs));
|
||||
ui.label(group(c.commits));
|
||||
ui.label(group(c.lines_added));
|
||||
ui.label(group(c.lines_deleted));
|
||||
ui.label(signed(c.lines_delta()));
|
||||
ui.label(group(c.binary_added));
|
||||
ui.label(group(c.binary_deleted));
|
||||
ui.label(day(c.first_commit));
|
||||
ui.label(day(c.last_commit));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
|
||||
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
|
||||
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
|
||||
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
|
||||
/// dropping any dragged widths.
|
||||
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
|
||||
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
|
||||
let s = ui.spacing();
|
||||
let gaps = s.item_spacing.x * natural.len() as f32;
|
||||
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
|
||||
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
|
||||
// The table keeps its own column widths, so a change in what they were fitted to has to clear
|
||||
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
|
||||
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
|
||||
let fit = egui::Id::unique(("credits_fit", id));
|
||||
let measured = (natural.to_vec(), ui.clip_rect().width());
|
||||
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
|
||||
ui.data_mut(|d| d.insert_temp(fit, measured));
|
||||
let mut t =
|
||||
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
|
||||
if refit {
|
||||
t.reset();
|
||||
}
|
||||
for w in widths {
|
||||
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
|
||||
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
|
||||
ui.with_layout(layout, add).inner
|
||||
}
|
||||
|
||||
fn row_height(ui: &egui::Ui) -> f32 {
|
||||
ui.text_style_height(&egui::TextStyle::Body) + 6.0
|
||||
}
|
||||
|
||||
/// One contributor's table cells after the name, in `SortKey::ALL` order.
|
||||
fn cells(c: &Contributor) -> [String; 9] {
|
||||
[
|
||||
group(c.prs),
|
||||
group(c.commits),
|
||||
group(c.lines_added),
|
||||
group(c.lines_deleted),
|
||||
signed(c.lines_delta()),
|
||||
group(c.binary_added),
|
||||
group(c.binary_deleted),
|
||||
day(c.first_commit).to_string(),
|
||||
day(c.last_commit).to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
|
||||
/// toolbar's sort-order button, so no arrow widens the column.
|
||||
fn header_text(k: SortKey) -> RichText {
|
||||
RichText::new(k.label().1).strong()
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
let h = row_height(ui);
|
||||
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
|
||||
let widths: Vec<f32> = SortKey::ALL
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &k)| match i.checked_sub(1) {
|
||||
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
|
||||
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
|
||||
})
|
||||
.collect();
|
||||
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for k in SortKey::ALL {
|
||||
header.col(|ui| {
|
||||
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0);
|
||||
|
||||
if r.clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for (c, cells) in list.iter().zip(rows) {
|
||||
body.row(h, |mut row| {
|
||||
row.col(|ui| {
|
||||
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
|
||||
.on_hover_text(c.summary());
|
||||
});
|
||||
for text in cells {
|
||||
row.col(|ui| {
|
||||
aligned(ui, true, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
@@ -302,22 +418,48 @@ pub fn models_ui(ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
let h = row_height(ui);
|
||||
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
|
||||
// Company, Model and Version read as text; the counts line up on the right.
|
||||
let right = [false, false, false, true, true, true];
|
||||
let rows: Vec<[String; 6]> = MODELS
|
||||
.iter()
|
||||
.map(|m| {
|
||||
[
|
||||
m.company.to_string(),
|
||||
m.model.to_string(),
|
||||
m.version.to_string(),
|
||||
group(m.commits),
|
||||
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
|
||||
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let widths: Vec<f32> = titles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
|
||||
.collect();
|
||||
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
|
||||
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for (title, r) in titles.into_iter().zip(right) {
|
||||
header.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(RichText::new(title).strong()));
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for cells in rows {
|
||||
body.row(h, |mut row| {
|
||||
for (text, r) in cells.into_iter().zip(right) {
|
||||
row.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -397,4 +539,131 @@ mod tests {
|
||||
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
|
||||
assert_eq!(day(""), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
|
||||
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
|
||||
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_little_room_shrinks_only_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
|
||||
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
|
||||
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
|
||||
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
|
||||
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
|
||||
}
|
||||
|
||||
const WINDOW: f32 = 640.5;
|
||||
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
|
||||
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
|
||||
|
||||
/// One frame of a table long enough to scroll; the rects of its header cells.
|
||||
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
scrolled_frame(ctx, width, natural, 0.0, events)
|
||||
}
|
||||
|
||||
/// [`table_frame`], scrolled `offset` points sideways.
|
||||
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
|
||||
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
|
||||
let mut cells = Vec::new();
|
||||
let out = ctx.run_ui(raw, |ui| {
|
||||
cells.clear();
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
|
||||
fitted_table(ui, "test", natural, &GROW)
|
||||
.header(20.0, |mut header| {
|
||||
for _ in natural {
|
||||
header.col(|ui| cells.push(ui.max_rect()));
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for _ in 0..40 {
|
||||
body.row(20.0, |mut row| {
|
||||
for _ in natural {
|
||||
row.col(|ui| {
|
||||
ui.label("x");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
out.drop_without_applying_deltas();
|
||||
cells
|
||||
}
|
||||
|
||||
/// A new context with the table open and settled, and the rects of its header cells.
|
||||
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
|
||||
let ctx = egui::Context::default();
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
(ctx, cells)
|
||||
}
|
||||
|
||||
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
|
||||
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
|
||||
let halfway = start + egui::vec2(by / 2.0, 0.0);
|
||||
let end = start + egui::vec2(by, 0.0);
|
||||
let moved = Event::PointerMoved;
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
|
||||
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
|
||||
let (_, opened) = opened_table();
|
||||
for (dragged, cell) in opened.iter().enumerate() {
|
||||
let (ctx, _) = opened_table();
|
||||
drag_edge(&ctx, *cell, -30.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
|
||||
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
|
||||
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_fit_replaces_the_column_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], -30.0);
|
||||
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
|
||||
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
|
||||
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
|
||||
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
|
||||
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
|
||||
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
|
||||
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
|
||||
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_sideways_keeps_dragged_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], 100.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
|
||||
for offset in [13.3, 27.77, 41.1, 0.6] {
|
||||
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,11 @@ pub(crate) struct Runtime {
|
||||
/// The desktop host turns the check at start on; tests and the web build leave it off.
|
||||
enabled: bool,
|
||||
check_requested: bool,
|
||||
/// A manual check's result dialog is showing (checking, up to date or failed).
|
||||
result_open: bool,
|
||||
}
|
||||
|
||||
impl Default for Runtime {
|
||||
fn default() -> Self {
|
||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false, result_open: false }
|
||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +29,15 @@ pub(crate) fn enable(app: &mut SlideApp) {
|
||||
pub(crate) fn request_check(app: &mut SlideApp) {
|
||||
if !matches!(app.updates_rt.updater.status(), Status::Checking) {
|
||||
app.updates_rt.check_requested = true;
|
||||
app.updates_rt.result_open = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the helper of an update that is ready, so that it installs once the process has exited.
|
||||
/// Later calls do nothing.
|
||||
pub(crate) fn start_pending_update(app: &mut SlideApp) {
|
||||
app.updates_rt.updater.start_pending_update();
|
||||
}
|
||||
|
||||
pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
let rt = &mut app.updates_rt;
|
||||
if std::mem::take(&mut rt.check_requested) {
|
||||
@@ -46,21 +49,31 @@ pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
|
||||
// An installation starts, and a ready update asks the app to close, only while nothing is unsaved.
|
||||
let clean = !app.session.documents().iter().any(|d| d.is_dirty());
|
||||
match app.updates_rt.updater.show_if(ctx, clean) {
|
||||
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
// The app's close path asks about unsaved work; the restart helper relaunches once the process exits.
|
||||
// The app's close path asks about unsaved work; `on_exit` starts the restart helper.
|
||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
Action::None => {}
|
||||
}
|
||||
// The result window reports a requested check, and at the first start after an update what
|
||||
// that update came to (installed, or why it failed).
|
||||
let rt = &mut app.updates_rt;
|
||||
if rt.result_open {
|
||||
match CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) {
|
||||
CheckResultResponse::Close => rt.result_open = false,
|
||||
CheckResultResponse::CheckAgain => rt.check_requested = true,
|
||||
CheckResultResponse::None => {}
|
||||
}
|
||||
if !matches!(rt.updater.status(), Status::Idle) {
|
||||
let response = CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status());
|
||||
answer(rt, response);
|
||||
}
|
||||
}
|
||||
|
||||
/// Acts on the result window's answer.
|
||||
fn answer(rt: &mut Runtime, response: CheckResultResponse) {
|
||||
match response {
|
||||
CheckResultResponse::Close => rt.updater.dismiss_status(),
|
||||
CheckResultResponse::CheckAgain => rt.check_requested = true,
|
||||
// The next frame's `show_if` asks the window to close once nothing is unsaved.
|
||||
CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(),
|
||||
CheckResultResponse::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,4 +90,62 @@ mod tests {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx())).textures_delta.clear();
|
||||
assert!(app.updates_rt.updater.prompt_open());
|
||||
}
|
||||
|
||||
/// An app whose updater keeps its outcome records under `root`, reads no launcher choice and
|
||||
/// asks no server, with the check at start turned on.
|
||||
fn app_with_records(root: &std::path::Path) -> SlideApp {
|
||||
let mut app = SlideApp::new(deckcraft_engine::Session::new(), crate::Services::default());
|
||||
app.updates_rt.updater = Updater::new(&crate::UPDATE_APP, VERSION)
|
||||
.with_paths(craft_update::Paths::under(root))
|
||||
.with_pins_path(root.join("pins.json"))
|
||||
.with_source(std::sync::Arc::new(|| Ok(None)));
|
||||
app.updates_rt.enabled = true;
|
||||
app
|
||||
}
|
||||
|
||||
fn records_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-updates-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// The first start after an update reports it; OK dismisses the report.
|
||||
#[test]
|
||||
fn the_start_after_an_update_reports_it_until_dismissed() {
|
||||
let root = records_dir("installed");
|
||||
std::fs::create_dir_all(root.join("data")).unwrap();
|
||||
std::fs::write(root.join("data").join("self-update-done.txt"), VERSION).unwrap();
|
||||
let mut app = app_with_records(&root);
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
tick(&mut app, ui.ctx());
|
||||
show(&mut app, ui.ctx());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert!(matches!(app.updates_rt.updater.status(), Status::Updated(_)), "{:?}", app.updates_rt.updater.status());
|
||||
answer(&mut app.updates_rt, CheckResultResponse::Close);
|
||||
assert_eq!(app.updates_rt.updater.status(), &Status::Idle);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The first start after a failed update says why; Check Again asks for a new check.
|
||||
#[test]
|
||||
fn the_start_after_a_failed_update_reports_why_and_offers_a_new_check() {
|
||||
let root = records_dir("failed");
|
||||
std::fs::create_dir_all(root.join("data").join("logs")).unwrap();
|
||||
std::fs::write(root.join("data").join("logs").join("self-update-error.txt"), "the disk is full").unwrap();
|
||||
let mut app = app_with_records(&root);
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
tick(&mut app, ui.ctx());
|
||||
show(&mut app, ui.ctx());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert_eq!(app.updates_rt.updater.status(), &Status::InstallFailed("the disk is full".into()));
|
||||
answer(&mut app.updates_rt, CheckResultResponse::CheckAgain);
|
||||
assert!(app.updates_rt.check_requested);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! The web build framed by a page that keeps the files (`?host=parent`): what Save, Save As,
|
||||
//! the exports and Print hand that page, under which name, and whether it is a new file.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Services, SlideApp};
|
||||
use crate::Session;
|
||||
|
||||
/// What the page was handed: (name, Save As, bytes).
|
||||
type Got = Rc<RefCell<Vec<(String, bool, Vec<u8>)>>>;
|
||||
|
||||
/// The web app inside such a page, with `host_save` and `download` pointed at it as the bridge
|
||||
/// does; `fail` makes every save miss the page.
|
||||
fn host_app(fail: bool) -> (SlideApp, Got) {
|
||||
let got: Got = Rc::default();
|
||||
let (saves, downloads) = (got.clone(), got.clone());
|
||||
let services = Services {
|
||||
host_save: Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
|
||||
if fail {
|
||||
return Err("the page is gone".into());
|
||||
}
|
||||
saves.borrow_mut().push((name.to_string(), save_as, bytes.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
download: Some(Box::new(move |name: &str, bytes: &[u8]| downloads.borrow_mut().push((name.to_string(), false, bytes.to_vec())))),
|
||||
..Default::default()
|
||||
};
|
||||
// Host mode starts as the web shell starts it: an empty presentation for the page's file.
|
||||
let mut app = SlideApp::new(Session::new(), services);
|
||||
app.start_for_host().unwrap();
|
||||
(app, got)
|
||||
}
|
||||
|
||||
/// A .pptx with `slides` slides, as the page would send it.
|
||||
fn pptx(slides: usize) -> Vec<u8> {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().unwrap().doc.slides.len() < slides {
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
}
|
||||
deckcraft_engine::cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap()
|
||||
}
|
||||
|
||||
fn edit(app: &mut SlideApp) {
|
||||
app.session.execute("slide.new", &json!({})).unwrap();
|
||||
assert!(app.session.active().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
fn calls(got: &Got) -> Vec<(String, bool)> {
|
||||
got.borrow().iter().map(|(n, s, _)| (n.clone(), *s)).collect()
|
||||
}
|
||||
|
||||
fn path(app: &SlideApp) -> Option<String> {
|
||||
app.session.active().and_then(|d| d.path.clone())
|
||||
}
|
||||
|
||||
/// The bug this prevents: Save of a .pptx the page sent came back as `<name>.deckcraft`. Save
|
||||
/// now hands the page the opened file under its own name and format, not as a new file; Save As
|
||||
/// says it is a new file and becomes the file the next Save writes.
|
||||
#[test]
|
||||
fn host_saves_go_back_to_the_opened_file() {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(Some("Lecture 3.pptx"), &pptx(2));
|
||||
assert_eq!(app.session.documents().len(), 1, "the untouched blank presentation gave way");
|
||||
edit(&mut app);
|
||||
app.save();
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
edit(&mut app);
|
||||
app.save_as_dialog("deckcraft");
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
edit(&mut app);
|
||||
app.save();
|
||||
let names = [("Lecture 3.pptx", false), ("Lecture 3.deckcraft", true), ("Lecture 3.deckcraft", false)];
|
||||
assert_eq!(calls(&got), names.map(|(n, s)| (n.to_string(), s)));
|
||||
// The first save is a .pptx that reopens with the edit in it.
|
||||
let first = got.borrow()[0].2.clone();
|
||||
assert!(first.windows(20).any(|w| w == b"ppt/presentation.xml"), "Save writes PowerPoint, not .deckcraft");
|
||||
let reopened = deckcraft_engine::cmd::file::loaded_file("Lecture 3.pptx", &first).unwrap();
|
||||
assert_eq!(reopened.doc.slides.len(), 3);
|
||||
}
|
||||
|
||||
/// A presentation the page never sent is a new file, as Save As: the page must not overwrite a
|
||||
/// file of the same name. The next Save goes back to it.
|
||||
#[test]
|
||||
fn host_save_of_an_untitled_presentation_is_save_as() {
|
||||
let (mut app, got) = host_app(false);
|
||||
let name = format!("{}.deckcraft", app.session.active().unwrap().title());
|
||||
edit(&mut app);
|
||||
app.save();
|
||||
edit(&mut app);
|
||||
app.save();
|
||||
assert_eq!(calls(&got), [(name.clone(), true), (name, false)]);
|
||||
}
|
||||
|
||||
/// A presentation the user dropped (or picked with File › Open) didn't come from the page, so
|
||||
/// its name isn't one the page holds: Save makes a new file.
|
||||
#[test]
|
||||
fn a_file_the_page_never_sent_saves_as_a_new_file() {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_or_insert_bytes("dropped.pptx", &pptx(1));
|
||||
app.save();
|
||||
assert_eq!(calls(&got), [("dropped.deckcraft".to_string(), true)]);
|
||||
}
|
||||
|
||||
/// Exports and Print are copies: named after the presentation, not a new file, and the
|
||||
/// presentation keeps its own file and its unsaved changes.
|
||||
#[test]
|
||||
fn host_exports_and_print_are_copies() {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(Some("deck.pptx"), &pptx(1));
|
||||
edit(&mut app);
|
||||
assert!(app.export_as("pdf", json!({})));
|
||||
assert!(app.print_pdf(json!({})));
|
||||
assert_eq!(calls(&got), [("deck.pdf".to_string(), false), ("deck.pdf".to_string(), false)]);
|
||||
assert!(app.session.active().unwrap().is_dirty());
|
||||
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
|
||||
}
|
||||
|
||||
/// The page not getting the file is a failed save: the presentation keeps its changes.
|
||||
#[test]
|
||||
fn a_host_save_that_fails_leaves_the_presentation_unsaved() {
|
||||
let (mut app, _) = host_app(true);
|
||||
app.open_from_host(Some("deck.pptx"), &pptx(1));
|
||||
edit(&mut app);
|
||||
app.save();
|
||||
assert!(app.session.active().unwrap().is_dirty());
|
||||
assert!(app.status.as_ref().is_some_and(|(m, _)| m.contains("Couldn't save deck.pptx")));
|
||||
}
|
||||
|
||||
/// Only an untouched start presentation gives way: one with changes stays open beside the file.
|
||||
#[test]
|
||||
fn an_edited_start_presentation_stays_open() {
|
||||
let (mut app, _) = host_app(false);
|
||||
edit(&mut app);
|
||||
app.open_from_host(Some("deck.pptx"), &pptx(1));
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
|
||||
}
|
||||
|
||||
/// Only the first file from the page takes the start presentation's place. The bug this
|
||||
/// prevents: a file sent without a name opens untitled and unchanged, as the start presentation
|
||||
/// is, so the page's next file closed it.
|
||||
#[test]
|
||||
fn only_the_first_file_from_the_page_replaces_the_start_presentation() {
|
||||
let (mut app, _) = host_app(false);
|
||||
app.open_from_host(None, &pptx(2));
|
||||
app.open_from_host(Some("deck.pptx"), &pptx(3));
|
||||
let slides: Vec<usize> = app.session.documents().iter().map(|d| d.doc.slides.len()).collect();
|
||||
assert_eq!(slides, [2, 3], "the file sent without a name stays open beside the next one");
|
||||
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
|
||||
}
|
||||
|
||||
/// The command palette runs `file.save` by id. With a file of its own the engine would write to
|
||||
/// that path, which a browser can't; in host mode it goes to the page like Ctrl+S.
|
||||
#[test]
|
||||
fn palette_save_goes_to_the_page() {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(Some("deck.pptx"), &pptx(1));
|
||||
edit(&mut app);
|
||||
let r = app.run("file.save", json!({})).unwrap();
|
||||
assert_eq!(r["path"], "deck.pptx");
|
||||
assert_eq!(calls(&got), [("deck.pptx".to_string(), false)]);
|
||||
assert!(!app.session.active().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
/// A file the page sent without a name, or with a name that isn't a presentation's, has no file
|
||||
/// of its own: Save is a new `.deckcraft`, never an overwrite of an invented `Presentation.pptx`.
|
||||
#[test]
|
||||
fn a_file_sent_without_a_presentation_name_saves_as_a_new_file() {
|
||||
for (name, saved) in [(None, "Presentation.deckcraft"), (Some("Talk"), "Talk.deckcraft")] {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(name, &pptx(2));
|
||||
assert_eq!(app.session.documents().len(), 1, "{name:?}");
|
||||
assert_eq!(app.session.active().unwrap().doc.slides.len(), 2, "{name:?}");
|
||||
assert_eq!(path(&app), None, "{name:?}");
|
||||
app.save();
|
||||
assert_eq!(calls(&got), [(saved.to_string(), true)], "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An outline from the page opens as a new untitled presentation, as File › Open does on the
|
||||
/// desktop: the blank start presentation gives way, and Save is a new file.
|
||||
#[test]
|
||||
fn an_outline_from_the_page_opens_as_a_new_presentation() {
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(Some("notes.md"), b"# One\n\n# Two\n\n# Three\n");
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
assert_eq!(app.session.active().unwrap().doc.slides.len(), 3);
|
||||
assert_eq!(path(&app), None);
|
||||
app.save();
|
||||
assert_eq!(calls(&got), [("notes.deckcraft".to_string(), true)]);
|
||||
}
|
||||
|
||||
/// A damaged file from the page opens as an untitled "(Repaired)" copy, so Save is a new file
|
||||
/// and can't overwrite the page's original with the emptied media.
|
||||
#[test]
|
||||
fn a_damaged_file_from_the_page_saves_as_a_new_repaired_file() {
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec());
|
||||
let mut bad = deckcraft_engine::cmd::file::save_bytes(&p, "deckcraft").unwrap();
|
||||
// Pictures are stored as they are, so flipping a byte breaks the entry's CRC.
|
||||
let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap();
|
||||
bad[at] ^= 0xff;
|
||||
let (mut app, got) = host_app(false);
|
||||
app.open_from_host(Some("talk.deckcraft"), &bad);
|
||||
assert_eq!(path(&app), None);
|
||||
assert!(app.session.active().unwrap().title().ends_with("(Repaired)"));
|
||||
app.save();
|
||||
assert_eq!(calls(&got), [("talk (Repaired).deckcraft".to_string(), true)]);
|
||||
}
|
||||
@@ -72,7 +72,7 @@ pub use panels::notes;
|
||||
pub use panels::selpane;
|
||||
pub use panels::thumbs;
|
||||
pub use ribbon::qat;
|
||||
pub use services::{DownloadFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn};
|
||||
pub use services::{DownloadFn, HostSaveFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn};
|
||||
use state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp};
|
||||
pub use state::{RECENT_MAX, UiState, ViewMode, ZOOM_RANGE};
|
||||
|
||||
@@ -335,6 +335,9 @@ pub struct SlideApp {
|
||||
next_file_id: u64,
|
||||
/// Picks and drops whose bytes have not arrived yet.
|
||||
pending_files: Vec<PendingFile>,
|
||||
/// The empty presentation host mode starts with ([`SlideApp::start_for_host`]), until the
|
||||
/// first file the page sends takes its place.
|
||||
host_start: Option<u64>,
|
||||
/// Owner/generation/cause bookkeeping for the canvas text IME producer.
|
||||
ime_tracker: egui::ime_context::ImeContextTracker,
|
||||
/// Canvas IME geometry for this pass: the caret while editing, or the selected shape.
|
||||
@@ -487,5 +490,8 @@ mod datetime_tests;
|
||||
#[cfg(test)]
|
||||
mod inbox_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod host_save_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -219,7 +219,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
// Only rows in view (plus a little margin) ask for a thumbnail: with every row asking, a
|
||||
// deck bigger than the thumbnail cache evicts its own thumbnails and never settles (#969).
|
||||
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * row.height())).intersects(row);
|
||||
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) } else { None };
|
||||
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, app.ui.grayscale, &mut budget) } else { None };
|
||||
match tex {
|
||||
Some(tex) => {
|
||||
p.image(
|
||||
|
||||
@@ -49,10 +49,15 @@ pub struct Snapshot {
|
||||
pub paused: bool,
|
||||
pub notes_px: f32,
|
||||
pub blank: Option<String>,
|
||||
/// All slides is open in the presenter window (#1866); the audience keeps the current slide.
|
||||
pub grid: bool,
|
||||
pub zoom_armed: bool,
|
||||
pub current: Option<Preview>,
|
||||
pub next: Option<Preview>,
|
||||
/// The All slides thumbnails, in slide order, while `grid` is on. `None` in a sent delta
|
||||
/// means "unchanged since the last snapshot the window received".
|
||||
#[serde(default)]
|
||||
pub slides: Option<Vec<Preview>>,
|
||||
}
|
||||
|
||||
/// Images acknowledged by a successful transport send, never merely attempted images.
|
||||
@@ -60,6 +65,7 @@ pub struct Snapshot {
|
||||
pub struct DeltaCache {
|
||||
current: Option<Preview>,
|
||||
next: Option<Preview>,
|
||||
slides: Option<Vec<Preview>>,
|
||||
token: String,
|
||||
}
|
||||
impl DeltaCache {
|
||||
@@ -75,6 +81,12 @@ impl DeltaCache {
|
||||
let next_changed = fresh || self.next != snapshot.next;
|
||||
let current = snapshot.current.clone();
|
||||
let next = snapshot.next.clone();
|
||||
let slides = snapshot.slides.clone();
|
||||
if !fresh && self.slides == slides {
|
||||
snapshot.slides = None;
|
||||
} else if snapshot.slides.is_none() {
|
||||
snapshot.slides = Some(Vec::new());
|
||||
}
|
||||
if !current_changed {
|
||||
snapshot.current = None;
|
||||
}
|
||||
@@ -84,14 +96,28 @@ impl DeltaCache {
|
||||
send(snapshot, current_changed, next_changed)?;
|
||||
self.current = current;
|
||||
self.next = next;
|
||||
self.slides = slides;
|
||||
self.token = token.to_owned();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Command {
|
||||
Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool },
|
||||
Zoom { x: f32, y: f32 },
|
||||
Key {
|
||||
name: String,
|
||||
command: bool,
|
||||
shift: bool,
|
||||
alt: bool,
|
||||
ctrl: bool,
|
||||
},
|
||||
Zoom {
|
||||
x: f32,
|
||||
y: f32,
|
||||
},
|
||||
/// A slide chosen in the presenter window's All slides grid (#1866).
|
||||
Goto {
|
||||
slide: usize,
|
||||
},
|
||||
End,
|
||||
}
|
||||
/// Implemented by the browser host. Native hosts retain the existing egui viewport.
|
||||
@@ -112,6 +138,8 @@ pub struct View {
|
||||
next: Option<egui::TextureHandle>,
|
||||
previous_current: Option<Preview>,
|
||||
previous_next: Option<Preview>,
|
||||
grid_source: Option<Vec<Preview>>,
|
||||
grid: Vec<Option<egui::TextureHandle>>,
|
||||
}
|
||||
impl View {
|
||||
/// Install the same interface and dynamic fallback fonts as SlideApp before layout.
|
||||
@@ -147,6 +175,10 @@ impl View {
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
for (key, modifiers) in keys {
|
||||
if snapshot.grid && matches!(key, egui::Key::Space | egui::Key::Enter) {
|
||||
// A focused thumbnail takes these to choose its slide; the show ignores them while the grid is open.
|
||||
continue;
|
||||
}
|
||||
// Consume once before widgets see Space/Enter, avoiding a second button activation.
|
||||
ui.input_mut(|i| i.consume_key(modifiers, key));
|
||||
commands.push(Command::Key {
|
||||
@@ -200,6 +232,11 @@ impl View {
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if snapshot.grid {
|
||||
// All slides opens here, as in the desktop Presenter View; the audience keeps the current slide.
|
||||
self.grid_ui(ui, snapshot, &mut commands);
|
||||
return commands;
|
||||
}
|
||||
ui.columns(2, |columns| {
|
||||
let left = &mut columns[0];
|
||||
if snapshot.ended {
|
||||
@@ -227,9 +264,6 @@ impl View {
|
||||
if snapshot.zoom_armed {
|
||||
left.label("Click the preview to zoom.");
|
||||
}
|
||||
if snapshot.grid {
|
||||
left.label("Choose a slide in the audience window, or press G to return.");
|
||||
}
|
||||
let right = &mut columns[1];
|
||||
right.label("Next");
|
||||
if let Some(texture) = &self.next {
|
||||
@@ -260,6 +294,43 @@ impl View {
|
||||
commands
|
||||
}
|
||||
}
|
||||
impl View {
|
||||
fn grid_ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot, commands: &mut Vec<Command>) {
|
||||
let slides = snapshot.slides.as_deref().unwrap_or_default();
|
||||
if self.grid_source.as_deref() != Some(slides) {
|
||||
self.grid_source = Some(slides.to_vec());
|
||||
self.grid = slides
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| p.image().map(|image| ui.ctx().load_texture(format!("remote-grid-{i}"), image, egui::TextureOptions::LINEAR)))
|
||||
.collect();
|
||||
}
|
||||
ui.heading("All slides");
|
||||
ui.label("Choose a slide to show it, or press G to return.");
|
||||
if snapshot.total > crate::show::GRID_CAP {
|
||||
ui.label(format!("First {} slides", crate::show::GRID_CAP));
|
||||
}
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, texture) in self.grid.iter().enumerate() {
|
||||
let size = egui::vec2(160.0, 90.0);
|
||||
let label = format!("{}", i + 1);
|
||||
let label = if i == snapshot.slide { egui::RichText::new(label).strong() } else { egui::RichText::new(label) };
|
||||
let response = match texture {
|
||||
Some(t) => ui.add(egui::Button::image_and_text(
|
||||
egui::Image::new(t).fit_to_exact_size(fit_preview(t.size_vec2(), size.x, size.y)),
|
||||
label,
|
||||
)),
|
||||
None => ui.add_sized(size, egui::Button::new(label)),
|
||||
};
|
||||
if response.clicked() {
|
||||
commands.push(Command::Goto { slide: i });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 {
|
||||
size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0))
|
||||
}
|
||||
@@ -282,6 +353,7 @@ mod tests {
|
||||
zoom_armed: false,
|
||||
current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }),
|
||||
next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }),
|
||||
slides: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +490,7 @@ mod tests {
|
||||
zoom_armed: false,
|
||||
current: None,
|
||||
next: None,
|
||||
slides: None,
|
||||
};
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
|
||||
@@ -437,6 +510,30 @@ mod tests {
|
||||
assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_slides_opens_in_the_popup_and_picks_a_slide() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let thumb = Preview { width: 16, height: 9, rgba: vec![200; 16 * 9 * 4] };
|
||||
let mut snapshot = snapshot();
|
||||
snapshot.total = 3;
|
||||
snapshot.grid = true;
|
||||
snapshot.slides = Some(vec![thumb.clone(), thumb.clone(), thumb]);
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
|
||||
sent.extend(view.ui(ui, snapshot));
|
||||
},
|
||||
(View::default(), snapshot, Vec::new()),
|
||||
);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Notes").is_none(), "the grid replaces the presenter panes, as on desktop (#1866)");
|
||||
h.get_by_label("3").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().2, vec![Command::Goto { slide: 2 }]);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().2.iter().any(|c| matches!(c, Command::Key { .. })), "Enter is left to the focused thumbnail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_are_bounded_and_bad_dimensions_are_rejected() {
|
||||
let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) {
|
||||
deckcraft_model::AnimStart::WithPrevious => "With Previous",
|
||||
deckcraft_model::AnimStart::AfterPrevious => "After Previous",
|
||||
});
|
||||
dropdown(ui, "astart", s.unwrap_or(""), 112.0, cur.is_some(), |ui| {
|
||||
dropdown(ui, "Start", s.unwrap_or(""), 112.0, cur.is_some(), |ui| {
|
||||
for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] {
|
||||
if ui.button(l).clicked() {
|
||||
run(app, "animation.timing", json!({"start": v}));
|
||||
|
||||
@@ -171,7 +171,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
||||
let size = st.get("size").and_then(Value::as_f64);
|
||||
let fams = font_families(app);
|
||||
let theme_fonts: Vec<String> = fams.iter().take(2).cloned().collect();
|
||||
dropdown(ui, "font", &font, 150.0, en, |ui| {
|
||||
dropdown(ui, "Font", &font, 150.0, en, |ui| {
|
||||
for (k, f) in fams.iter().enumerate() {
|
||||
let label = if k == 0 && theme_fonts.len() == 2 {
|
||||
format!("{f} (Headings)")
|
||||
@@ -196,7 +196,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
||||
}
|
||||
});
|
||||
let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default();
|
||||
dropdown(ui, "size", &size_text, 52.0, en, |ui| {
|
||||
dropdown(ui, "Font size", &size_text, 52.0, en, |ui| {
|
||||
for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) {
|
||||
if ui.button(widgets::fmt_num(*s, "")).clicked() {
|
||||
run(app, "format.size", json!({"size": s}));
|
||||
|
||||
@@ -11,6 +11,10 @@ pub type OpenAsyncFn = Box<dyn FnMut(&str, u64)>;
|
||||
|
||||
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
|
||||
|
||||
/// [`Services::host_save`]: `(file name, bytes, save_as)`. An error means the page didn't get
|
||||
/// the file, and the save failed.
|
||||
pub type HostSaveFn = Box<dyn FnMut(&str, &[u8], bool) -> Result<(), String>>;
|
||||
|
||||
/// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InboxFile {
|
||||
@@ -49,6 +53,15 @@ pub struct Services {
|
||||
pub open_async: Option<OpenAsyncFn>,
|
||||
/// Hand bytes to the user as a named file (browser download).
|
||||
pub download: Option<DownloadFn>,
|
||||
/// Web, framed by a page that keeps the files (`?host=parent`): Save and Save As hand the
|
||||
/// presentation to that page instead of downloading it, with whether it is a new file
|
||||
/// ([`HostSaveFn`]). Save writes a presentation the page sent back under its own name and
|
||||
/// format, as the desktop app writes back to the file it opened; a presentation the page
|
||||
/// never sent (an untitled one, a repaired copy) goes as a new `.deckcraft` file, as Save
|
||||
/// suggests on the desktop. Save As (.pptx or .deckcraft) is always a new file. Either way the
|
||||
/// presentation is then that file, and the next Save goes to it. Exports and Print still go
|
||||
/// through `download`, which the host also points at the page: they are copies.
|
||||
pub host_save: Option<HostSaveFn>,
|
||||
pub inbox: Option<Inbox>,
|
||||
/// Image on the system clipboard (PNG bytes), when the host can read one.
|
||||
pub clipboard_image: Option<Box<dyn FnMut() -> Option<Vec<u8>>>>,
|
||||
@@ -58,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>>>,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ mod presenter;
|
||||
mod render;
|
||||
mod ui;
|
||||
mod zoom;
|
||||
pub(crate) use zoom::GRID_CAP;
|
||||
|
||||
#[cfg(test)]
|
||||
use media::{on_screen, pointer_on_media_bar, shown_slide_rect, slide_media_bars};
|
||||
@@ -65,6 +66,8 @@ pub struct Show {
|
||||
preview_spawns: usize,
|
||||
remote_preview: Option<crate::presenter::Preview>,
|
||||
remote_next: Option<(usize, crate::presenter::Preview)>,
|
||||
/// All slides thumbnails sent to the web presenter window while its grid is open (#1866).
|
||||
remote_grid: Vec<crate::presenter::Preview>,
|
||||
remote_sent: f64,
|
||||
remote_opened: bool,
|
||||
/// The next slide's first frame, rendered in the background while this one is shown so
|
||||
@@ -237,6 +240,7 @@ impl Show {
|
||||
preview_spawns: 0,
|
||||
remote_preview: None,
|
||||
remote_next: None,
|
||||
remote_grid: vec![],
|
||||
remote_sent: f64::NEG_INFINITY,
|
||||
remote_opened: false,
|
||||
prefetch: None,
|
||||
|
||||
@@ -1132,6 +1132,8 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||
opens: usize,
|
||||
closes: usize,
|
||||
commands: Vec<Command>,
|
||||
last: Option<Snapshot>,
|
||||
gone: bool,
|
||||
}
|
||||
struct Mock(Rc<RefCell<Bridge>>);
|
||||
impl Host for Mock {
|
||||
@@ -1140,12 +1142,14 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||
Ok(())
|
||||
}
|
||||
fn is_open(&self) -> bool {
|
||||
true
|
||||
!self.0.borrow().gone
|
||||
}
|
||||
fn commands(&mut self) -> Vec<Command> {
|
||||
std::mem::take(&mut self.0.borrow_mut().commands)
|
||||
}
|
||||
fn update(&mut self, _: Snapshot) {}
|
||||
fn update(&mut self, snapshot: Snapshot) {
|
||||
self.0.borrow_mut().last = Some(snapshot);
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.borrow_mut().closes += 1;
|
||||
}
|
||||
@@ -1179,6 +1183,33 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide, "{name}");
|
||||
}
|
||||
// #1866: All slides opens in the web Presenter View, as on desktop; the audience keeps its slide.
|
||||
bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false });
|
||||
h.run_steps(2);
|
||||
let show = h.state().show.as_ref().unwrap();
|
||||
assert!(show.presenter_grid && !show.grid, "G from the popup opens its own grid, not the audience's");
|
||||
for _ in 0..20 {
|
||||
h.step();
|
||||
std::thread::sleep(std::time::Duration::from_millis(15));
|
||||
}
|
||||
let last = bridge.borrow().last.clone().unwrap();
|
||||
assert!(last.grid);
|
||||
assert_eq!(last.slides.as_ref().map(Vec::len), Some(3), "the popup gets a thumbnail per slide");
|
||||
assert!(last.slides.iter().flatten().all(|p| p.width.max(p.height) <= 96), "thumbnails stay small enough for one message");
|
||||
assert!(last.current.is_none() && last.next.is_none(), "the grid replaces both previews");
|
||||
bridge.borrow_mut().commands.push(Command::Goto { slide: 2 });
|
||||
h.run_steps(2);
|
||||
let show = h.state().show.as_ref().unwrap();
|
||||
assert!(!show.presenter_grid && !show.grid);
|
||||
assert_eq!(show.state.slide, 2);
|
||||
// Closing the popup with its grid open must not hold auto-advance for the rest of the show.
|
||||
bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false });
|
||||
h.run_steps(2);
|
||||
assert!(h.state().show.as_ref().unwrap().presenter_grid);
|
||||
bridge.borrow_mut().gone = true;
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().show.as_ref().unwrap().presenter_grid);
|
||||
bridge.borrow_mut().gone = false;
|
||||
bridge.borrow_mut().commands.push(Command::End);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().show.is_none());
|
||||
|
||||
@@ -80,6 +80,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
&& !show.reading
|
||||
&& let Some(host) = app.services.presenter.as_mut()
|
||||
{
|
||||
if show.remote_opened && show.presenter_grid && !host.is_open() {
|
||||
// A grid left open in the closed popup would hold auto-advance for the rest of the show.
|
||||
show.presenter_grid = false;
|
||||
}
|
||||
if !show.remote_opened {
|
||||
show.remote_opened = true;
|
||||
if let Err(message) = host.open() {
|
||||
@@ -96,9 +100,18 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
|
||||
if let Some(key) = egui::Key::from_name(&name) {
|
||||
let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl };
|
||||
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0;
|
||||
// The popup is the Presenter View: G opens All slides there, not on the audience screen (#1866).
|
||||
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], true).0;
|
||||
}
|
||||
}
|
||||
crate::presenter::Command::Goto { slide } if show.presenter_grid && slide < doc.slides.len() => {
|
||||
show.presenter_grid = false;
|
||||
show.blank = None;
|
||||
show.zoom = None;
|
||||
show.zoom_arm = false;
|
||||
let action = show.state.goto(&doc, slide);
|
||||
out.keep &= show.apply(action, &doc, now);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +156,20 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
);
|
||||
show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image));
|
||||
}
|
||||
if !show.presenter_grid {
|
||||
show.remote_grid.clear();
|
||||
} else if show.remote_grid.len() < grid_indices(doc.slides.len()).len() {
|
||||
// A few thumbnails per update keep the audience window responsive on the web.
|
||||
for i in grid_indices(doc.slides.len()).into_iter().skip(show.remote_grid.len()).take(4) {
|
||||
// At most 96 px on the long side: 48 thumbnails stay well under the popup's message limit.
|
||||
let (w, h) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0));
|
||||
let scale = REMOTE_THUMB / w.max(h);
|
||||
let size = (((w * scale).round() as u32).max(1), ((h * scale).round() as u32).max(1));
|
||||
let image = deckcraft_render::render_slide(&doc, i, &RenderOpts { scale, size: Some(size), ..Default::default() });
|
||||
let preview = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false));
|
||||
show.remote_grid.push(preview.unwrap_or(crate::presenter::Preview { width: 1, height: 1, rgba: vec![0; 4] }));
|
||||
}
|
||||
}
|
||||
host.update(crate::presenter::Snapshot {
|
||||
slide: show.state.slide,
|
||||
total: doc.slides.len(),
|
||||
@@ -152,10 +179,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
paused: show.timer.paused(),
|
||||
notes_px: show.notes_px,
|
||||
blank: show.blank_name().map(str::to_owned),
|
||||
grid: show.grid,
|
||||
grid: show.presenter_grid,
|
||||
zoom_armed: show.zoom_arm,
|
||||
current: show.remote_preview.clone(),
|
||||
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()),
|
||||
// The grid replaces both previews in the popup; leaving them out keeps one message small.
|
||||
current: show.remote_preview.clone().filter(|_| !show.presenter_grid),
|
||||
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next && !show.presenter_grid).map(|(_, image)| image.clone()),
|
||||
slides: show.presenter_grid.then(|| show.remote_grid.clone()),
|
||||
});
|
||||
show.remote_sent = now;
|
||||
}
|
||||
@@ -164,6 +193,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
finish(app, show, &f, out.keep);
|
||||
}
|
||||
|
||||
/// Longest side of an All slides thumbnail sent to the web presenter window (#1866).
|
||||
const REMOTE_THUMB: f64 = 96.0;
|
||||
|
||||
fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) {
|
||||
if show.started {
|
||||
let full = ui.max_rect();
|
||||
|
||||
@@ -182,7 +182,7 @@ const WHEEL_NOTCHES_PER_DOUBLING: f32 = 4.0;
|
||||
/// PowerPoint's slide show magnifier goes up to 400%.
|
||||
const WHEEL_ZOOM_MAX: f32 = 4.0;
|
||||
|
||||
pub(super) const GRID_CAP: usize = 48;
|
||||
pub(crate) const GRID_CAP: usize = 48;
|
||||
|
||||
const TOOL_BAR_W: f32 = 520.0;
|
||||
|
||||
|
||||
@@ -209,7 +209,8 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
|
||||
// Only cells in view (plus a little margin) ask for a thumbnail, so a deck bigger
|
||||
// than the thumbnail cache does not evict its own thumbnails forever (#969).
|
||||
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * cell.height())).intersects(cell);
|
||||
let tex = if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) } else { None };
|
||||
let tex =
|
||||
if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, app.ui.grayscale, budget) } else { None };
|
||||
match tex {
|
||||
Some(tex) => {
|
||||
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
@@ -391,7 +392,7 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mut budget = 2;
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, &mut budget) {
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, app.ui.grayscale, &mut budget) {
|
||||
ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside);
|
||||
@@ -476,7 +477,7 @@ fn outline_estimate(s: &deckcraft_model::Slide) -> f32 {
|
||||
let lines: usize = s
|
||||
.shapes
|
||||
.iter()
|
||||
.filter(|x| !x.ph_type().is_some_and(|k| k.is_title()))
|
||||
.filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()))
|
||||
.filter_map(|x| x.text.as_ref())
|
||||
.filter(|tb| !tb.is_empty())
|
||||
.map(|tb| tb.paragraphs.len().max(1))
|
||||
@@ -512,7 +513,8 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
});
|
||||
for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) {
|
||||
// Footer, date and slide-number placeholders are not outline text, as in PowerPoint.
|
||||
for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind())) {
|
||||
let Some(tb) = &sh.text else { continue };
|
||||
if tb.is_empty() {
|
||||
continue;
|
||||
|
||||
@@ -259,3 +259,24 @@ fn shift_and_ctrl_shift_clicks_select_ranges() {
|
||||
click(&mut h, 4, Modifiers::COMMAND | Modifiers::SHIFT);
|
||||
assert_eq!(selected(&h), vec![0, 2, 3]);
|
||||
}
|
||||
|
||||
/// The outline view lists a slide's title and body text, not its footer, date and slide-number
|
||||
/// placeholders, as PowerPoint's does: turning them on leaves the slide's outline block as it was.
|
||||
#[test]
|
||||
fn the_outline_view_leaves_footer_date_and_slide_number_out() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap();
|
||||
let slide = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
st.doc.slides[st.selection.slide].clone()
|
||||
};
|
||||
let plain = super::outline_estimate(&slide(&s));
|
||||
s.execute(
|
||||
"design.headerFooter",
|
||||
&json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}),
|
||||
)
|
||||
.unwrap();
|
||||
let with_footer = slide(&s);
|
||||
assert!(with_footer.shapes.iter().any(|x| x.ph_type().is_some_and(|k| k.is_footer_kind())), "the footers are on the slide");
|
||||
assert_eq!(super::outline_estimate(&with_footer), plain);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
@@ -1175,6 +1223,56 @@ fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> {
|
||||
h
|
||||
}
|
||||
|
||||
/// #1855: text pasted into the focused speaker-notes field lands at its caret. While a stock
|
||||
/// field has the focus, `SlideApp::keyboard` leaves the batch to it and nothing in the app drops
|
||||
/// `Paste`, whether it arrives alone or inside the ⌘V batch egui-winit sends (V down, Paste, V up).
|
||||
#[test]
|
||||
fn paste_reaches_the_focused_notes_field() {
|
||||
let mut h = step_app(Services::default());
|
||||
let id = egui::Id::unique("notes_edit");
|
||||
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.memory(|m| m.has_focus(id)), "the notes field has the focus");
|
||||
h.event(egui::Event::Text("ab".into()));
|
||||
h.run_steps(2);
|
||||
h.event(egui::Event::Paste("PASTED".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().notes_buf.2.text, "abPASTED", "a bare Paste event");
|
||||
let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND };
|
||||
h.input_mut().events.extend([v(true), egui::Event::Paste("TWO".into()), v(false)]);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().notes_buf.2.text, "abPASTEDTWO", "the ⌘V batch");
|
||||
// Leaving the field commits the notes to the slide.
|
||||
h.ctx.memory_mut(|m| m.surrender_focus(id));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.slides[0].notes_text(), "abPASTEDTWO");
|
||||
}
|
||||
|
||||
/// #1956: the ribbon's drop-downs are announced by their purpose, not by their value or an
|
||||
/// internal key. Animations › Start with a shape that has no animation was "astart"; Home's Font
|
||||
/// and Font size boxes were "font" and "size" while nothing told them a font or size.
|
||||
#[test]
|
||||
fn ribbon_dropdowns_are_named_by_purpose_even_when_empty() {
|
||||
use egui::accesskit::Role;
|
||||
let mut h = step_app(Services::default());
|
||||
// A fresh shape, selected but not edited and without an animation.
|
||||
h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0]})).unwrap();
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
h.state_mut().ui.tab = "Animations".into();
|
||||
h.run_steps(2);
|
||||
let start = h.get_by_role_and_label(Role::ComboBox, "Start");
|
||||
assert_eq!(start.value(), None, "no animation, no start value");
|
||||
assert!(h.query_by_label("astart").is_none(), "the internal key is not a name");
|
||||
|
||||
h.state_mut().ui.tab = "Home".into();
|
||||
h.state_mut().run("edit.select", json!({"ids": []})).unwrap();
|
||||
h.run_steps(2);
|
||||
for (name, key) in [("Font", "font"), ("Font size", "size")] {
|
||||
assert!(h.query_by_role_and_label(Role::ComboBox, name).is_some(), "{name} is named while empty");
|
||||
assert!(h.query_by_role_and_label(Role::ComboBox, key).is_none(), "{key} is not announced");
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold.
|
||||
#[test]
|
||||
fn copy_offers_html_and_paste_keeps_bold() {
|
||||
|
||||
@@ -361,9 +361,19 @@ impl Textures {
|
||||
self.thumbs.clear();
|
||||
}
|
||||
|
||||
/// A thumbnail texture; renders at most `budget` new thumbnails per frame (returns None when
|
||||
/// it is over budget, so the caller draws a placeholder and asks for another frame).
|
||||
pub fn thumb(&mut self, ctx: &egui::Context, p: &Presentation, index: usize, width_px: u32, budget: &mut u32) -> Option<TextureHandle> {
|
||||
/// A thumbnail texture; renders at most `budget` new thumbnails per frame. Over budget it
|
||||
/// returns the slide's previous thumbnail if there is one (and asks for another frame), else
|
||||
/// None, so the caller draws a placeholder.
|
||||
/// When `grayscale` is true, the thumbnail renders in black-and-white (matching Grayscale view).
|
||||
pub fn thumb(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
p: &Presentation,
|
||||
index: usize,
|
||||
width_px: u32,
|
||||
grayscale: bool,
|
||||
budget: &mut u32,
|
||||
) -> Option<TextureHandle> {
|
||||
let slide = p.slides.get(index)?.clone();
|
||||
let master = master_of(p, &slide);
|
||||
let h = (width_px as f64 * p.slide_size.height / p.slide_size.width.max(1.0)).round().max(1.0) as u32;
|
||||
@@ -372,7 +382,7 @@ impl Textures {
|
||||
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
|
||||
size: (width_px, h),
|
||||
edit: false,
|
||||
extra: 0,
|
||||
extra: grayscale as u64,
|
||||
want: Region::slide(width_px, h),
|
||||
editing_text: None,
|
||||
};
|
||||
@@ -385,7 +395,8 @@ impl Textures {
|
||||
return Some(e.tex.clone());
|
||||
}
|
||||
if *budget == 0 {
|
||||
// Keep showing the stale thumbnail while the new one waits.
|
||||
// Keep showing the stale thumbnail while the new one waits; request a repaint to finish rendering.
|
||||
ctx.request_repaint();
|
||||
return self.thumbs.get(&key.slide).map(|e| e.tex.clone());
|
||||
}
|
||||
*budget -= 1;
|
||||
@@ -393,7 +404,7 @@ impl Textures {
|
||||
let scale = tw as f64 / p.slide_size.width.max(1.0);
|
||||
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() });
|
||||
self.thumb_renders += 1;
|
||||
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, false));
|
||||
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, grayscale));
|
||||
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
|
||||
let region = key.want;
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
|
||||
@@ -433,6 +444,20 @@ mod tests {
|
||||
assert_eq!(device_pixel_rect(aligned, ppp), aligned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_color_image_turns_colors_to_grayscale_when_flag_is_set() {
|
||||
let img = deckcraft_render::Image { width: 2, height: 1, pixels: vec![0, 0, 255, 255, 255, 0, 0, 255] };
|
||||
let color = super::to_color_image(&img, false);
|
||||
assert_eq!(color.pixels[0], egui::Color32::from_rgba_premultiplied(0, 0, 255, 255));
|
||||
assert_eq!(color.pixels[1], egui::Color32::from_rgba_premultiplied(255, 0, 0, 255));
|
||||
|
||||
let gray = super::to_color_image(&img, true);
|
||||
assert_eq!(gray.pixels[0].r(), gray.pixels[0].g());
|
||||
assert_eq!(gray.pixels[0].g(), gray.pixels[0].b());
|
||||
assert_eq!(gray.pixels[1].r(), gray.pixels[1].g());
|
||||
assert_eq!(gray.pixels[1].g(), gray.pixels[1].b());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_scale_does_not_move_the_rect() {
|
||||
let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0));
|
||||
|
||||
@@ -330,7 +330,9 @@ pub fn rows<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
}
|
||||
|
||||
/// A text dropdown field (font name, size): shows `current`, opens a popup with `add_items`.
|
||||
pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response {
|
||||
/// `label` is what assistive technology calls it ("Font", "Start"); `current` is exposed as its
|
||||
/// value, so an empty box is still announced by its purpose and never by an internal key (#1956).
|
||||
pub fn dropdown(ui: &mut Ui, label: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 22.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
let painter = ui.painter_at(rect);
|
||||
@@ -350,8 +352,10 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool,
|
||||
if enabled { t.text } else { t.text_faint },
|
||||
);
|
||||
chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, if current.is_empty() { id } else { current }));
|
||||
let _ = id;
|
||||
resp.widget_info(|| egui::WidgetInfo {
|
||||
current_text_value: (!current.is_empty()).then(|| current.to_string()),
|
||||
..egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, label)
|
||||
});
|
||||
egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| {
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items);
|
||||
});
|
||||
@@ -742,19 +746,26 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The custom ribbon dropdown is a real combo box in the accessibility tree, named by its
|
||||
/// current value (0.37's strict unnamed-input check would otherwise reject it).
|
||||
/// purpose with the shown text as its value. An empty one (no selection to read a font or
|
||||
/// size from) keeps its name rather than falling back to an internal key (#1956).
|
||||
#[test]
|
||||
fn the_dropdown_has_a_combo_box_role_and_a_name() {
|
||||
fn the_dropdown_is_named_by_its_purpose_and_carries_its_value() {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| {
|
||||
theme::install_fonts(ui.ctx());
|
||||
dropdown(ui, "font-size", "18 pt", 120.0, true, |ui| {
|
||||
dropdown(ui, "Font size", "18 pt", 120.0, true, |ui| {
|
||||
ui.label("12 pt");
|
||||
ui.label("18 pt");
|
||||
});
|
||||
dropdown(ui, "Start", "", 112.0, false, |_| {});
|
||||
});
|
||||
harness.run();
|
||||
let combo = harness.get_by_role_and_label(Role::ComboBox, "18 pt");
|
||||
let combo = harness.get_by_role_and_label(Role::ComboBox, "Font size");
|
||||
assert_eq!(combo.value().as_deref(), Some("18 pt"));
|
||||
assert!(combo.rect().width() > 100.0);
|
||||
assert!(harness.query_by_role_and_label(Role::ComboBox, "18 pt").is_none(), "the value is not its name");
|
||||
let empty = harness.get_by_role_and_label(Role::ComboBox, "Start");
|
||||
assert_eq!(empty.value(), None, "nothing shown, no value");
|
||||
assert!(harness.query_by_label("astart").is_none() && harness.query_by_label("start").is_none(), "no internal key is announced");
|
||||
}
|
||||
|
||||
/// #32: clicking the "More Colors" hex field closed the colour menu before anything could be
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,7 +84,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
|
||||
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Run `lang` and `altLang` are independently retained through native and PPTX saves; other round-trip tests cover `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) |
|
||||
| Effects: shadow, glow, soft edges, reflection | yes | yes | On a run, inner shadow, reflection and soft edges are kept along with the outer shadow and glow |
|
||||
| 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately |
|
||||
| 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. `scene3d`, `sp3d` and `flatTx` on a text body (`a:bodyPr`) and `scene3d` on a group (`p:grpSpPr`) are kept as raw XML too. Bevels, cameras, face lighting and extrusion sides draw approximately |
|
||||
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
|
||||
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
|
||||
| Tables with styles | yes | yes | Merges, both diagonal borders and optional RTL column direction round-trip; Reuse Slides transfers definitions referenced by source tables, including grouped tables, with conflicting ids remapped |
|
||||
|
||||
+3
-2
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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% |
|
||||
|
||||
+80
-2
@@ -65,12 +65,89 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
|
||||
- The app fills the iframe and follows its size, so size the iframe and not the app.
|
||||
- Keyboard shortcuts go to the iframe after the user clicks into it, as with any embedded app.
|
||||
- **Cross-origin embeds** work. The web build keeps no preferences between visits; it starts
|
||||
with the sample deck (or `?blank`) every time.
|
||||
with the sample deck (or `?blank`) every time. Opening and saving through the page
|
||||
(`?host=parent`, below) needs the same origin.
|
||||
- **Sandboxed iframes** need at least
|
||||
`sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"`. Without `allow-downloads`, Save and Export (browser
|
||||
downloads) are blocked.
|
||||
downloads) are blocked. With `?host=parent`, `allow-scripts allow-same-origin` is enough for
|
||||
opening and saving (without `allow-same-origin` the frame's origin is `null` and there is no
|
||||
bridge), and `allow-downloads` isn't needed.
|
||||
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
|
||||
|
||||
## Opening and saving through the page (`?host=parent`)
|
||||
|
||||
A page that keeps the files itself (a Nextcloud app, a file server) can frame DeckCraft with
|
||||
`?host=parent` on the iframe `src`, hand it a presentation and take back what it saves, instead of
|
||||
the user downloading. GridCraft, WordCraft and PdfCraft have the same bridge with their own prefix.
|
||||
The page and DeckCraft must be on the **same origin**: DeckCraft only hears and answers its parent
|
||||
window on its own origin, and without one it downloads as usual.
|
||||
|
||||
Messages are plain objects with a `type`:
|
||||
|
||||
| Message | Direction | Fields |
|
||||
|---|---|---|
|
||||
| `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) or refused a file of 2 GiB or larger. |
|
||||
|
||||
`saveAs` says whether the file becomes the presentation's file, as on the desktop:
|
||||
|
||||
- **Save** sends the file the page sent, under its own name and format (`Talk.pptx` comes back
|
||||
as `Talk.pptx`), with `saveAs: false`. DeckCraft keeps only the part after the last `/`, so
|
||||
`docs/Talk.pptx` also comes back as `Talk.pptx`. A presentation that has no file the page sent
|
||||
(a new one, an outline, a file sent without a name, a file the user dropped, a repaired copy of
|
||||
a damaged file) is a new file with `saveAs: true`: Ctrl+S names it `<title>.deckcraft` as Save
|
||||
suggests on the desktop, and File › Save opens the Save As page first, as on the desktop.
|
||||
- **Save As** (.pptx or .deckcraft) sends `saveAs: true`. The presentation is now that file; the
|
||||
next Save sends its name with `saveAs: false`.
|
||||
- **Exports and Print** (PDF, handouts, pictures, outline, GIF) send `saveAs: false` and are
|
||||
copies: the presentation keeps its file and its unsaved changes.
|
||||
|
||||
`saveAs: false` doesn't mean "safe to overwrite": an export is `false` under a name the page may
|
||||
already hold (`Talk.pdf`, or `Talk.pptx` exported from a presentation that is now
|
||||
`Talk.deckcraft`). Replace a file without asking only when the name is the one the page sent, or
|
||||
the name of the last `saveAs: true` file it stored for this presentation; ask for anything else.
|
||||
DeckCraft doesn't learn the name the page finally stores a Save As under, so a page that renames
|
||||
on a conflict should map later Saves of the old name itself.
|
||||
|
||||
In host mode DeckCraft starts with an empty presentation instead of the sample, and the first
|
||||
presentation the page sends takes its place unless the user has changed it; later ones open
|
||||
beside it. The page guards leaving itself (DeckCraft adds no guard of its own), and
|
||||
`&author=Name` signs the user's comments.
|
||||
|
||||
```html
|
||||
<iframe id="deck" src="/deckcraft/?host=parent&author=Ada" style="width: 100%; height: 720px; border: 0;"></iframe>
|
||||
<script>
|
||||
const frame = document.getElementById("deck");
|
||||
addEventListener("message", async (e) => {
|
||||
if (e.origin !== location.origin || e.source !== frame.contentWindow) return;
|
||||
if (e.data.type === "deckcraft:ready") {
|
||||
const bytes = await (await fetch("/files/Talk.pptx")).arrayBuffer();
|
||||
frame.contentWindow.postMessage({ type: "deckcraft:open", name: "Talk.pptx", bytes }, location.origin, [bytes]);
|
||||
} else if (e.data.type === "deckcraft:save") {
|
||||
// Store e.data.bytes as e.data.name. Replace silently only when !e.data.saveAs and the name
|
||||
// is the one sent (or the last saveAs file stored); ask before replacing anything else.
|
||||
}
|
||||
});
|
||||
</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`.
|
||||
|
||||
### Driving it from the page (`window.deckcraft`)
|
||||
|
||||
Every DeckCraft web build puts `window.deckcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `deckcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects.
|
||||
|
||||
```js
|
||||
const deck = await deckcraft.docInspect(); // a summary of the open presentation
|
||||
await deckcraft.execute("shape.insert", { preset: "roundRect", rect: [100, 100, 240, 120], text: "Hello" });
|
||||
const { data } = await deckcraft.screenshot(); // the canvas as PNG (base64)
|
||||
```
|
||||
|
||||
The API is there in every mode, with or without a host. Opening a file, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
|
||||
|
||||
## Renderer selection and fallback flags
|
||||
|
||||
DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||
@@ -82,6 +159,7 @@ DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and fall
|
||||
| `?webgl` | Force the WebGL2 backend (useful when a WebGPU driver misbehaves) |
|
||||
| `?blank` | Start with an empty presentation instead of the sample deck |
|
||||
| `?show` | Start the slide show immediately (kiosk/embedded decks) |
|
||||
| `?host=parent` | Open and save through the framing page ([above](#opening-and-saving-through-the-page-hostparent)); `&author=` names the user |
|
||||
|
||||
For example: `<iframe src="https://example.com/deckcraft/?webgl" ...>`.
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const SHARED_REV: &str = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7";
|
||||
const SHARED_REPO: &str = "https://github.com/storytold/craft-libs";
|
||||
const SHARED_REV: &str = "1b34eba41e62138fc2438d2734523b302840b45d";
|
||||
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
@@ -70,7 +71,7 @@ fn validate(metadata: &Value) -> Result<(), String> {
|
||||
}
|
||||
matches.first().copied().ok_or_else(|| format!("missing {name}"))
|
||||
};
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
||||
let source = format!("git+{SHARED_REPO}?rev={SHARED_REV}#{SHARED_REV}");
|
||||
for (name, version, expected_source, expected_features) in [
|
||||
("craft-pdf", "0.1.0", source.as_str(), vec!["write", "write-simple-text"]),
|
||||
("krilla", "0.8.2", source.as_str(), vec!["raster-images", "rayon", "simple-text"]),
|
||||
@@ -143,7 +144,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
fn metadata() -> Value {
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
||||
let source = format!("git+{SHARED_REPO}?rev={SHARED_REV}#{SHARED_REV}");
|
||||
json!({
|
||||
"packages": [
|
||||
{"id":"app", "name":"deckcraft-pdf"},
|
||||
|
||||
Reference in New Issue
Block a user