diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538..ea61d843 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,27 @@ jobs: lint: # 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/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index d2f76f7b..f577b3f7 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -24,8 +24,10 @@ permissions: jobs: windows-arm64: - # 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/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: deckcraft app-dir: DeckCraft + install-relative-path: Programs/ArtCraft/DeckCraft + install-scope: perUser diff --git a/CLAUDE.md b/CLAUDE.md index 67853ff0..aae6e13f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,10 @@ 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. Read `../../craftrules/AGENTS.md` and the standards it lists for your task. diff --git a/Cargo.lock b/Cargo.lock index 0986dafc..ed9a4995 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -642,6 +642,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -811,10 +820,38 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] + [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "thiserror 2.0.21", ] @@ -822,16 +859,36 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "krilla", "thiserror 2.0.21", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-fonts-build", + "craft-i18n", + "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -926,7 +983,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "deckcraft" -version = "0.11.1" +version = "0.12.0" dependencies = [ "arboard", "chrono", @@ -948,7 +1005,7 @@ dependencies = [ [[package]] name = "deckcraft-anim" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-geom", "deckcraft-model", @@ -957,7 +1014,7 @@ dependencies = [ [[package]] name = "deckcraft-av1" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -966,14 +1023,14 @@ dependencies = [ [[package]] name = "deckcraft-bitstream" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-cli" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-engine", "deckcraft-mcp", @@ -983,14 +1040,14 @@ dependencies = [ [[package]] name = "deckcraft-color" -version = "0.11.1" +version = "0.12.0" dependencies = [ "serde", ] [[package]] name = "deckcraft-engine" -version = "0.11.1" +version = "0.12.0" dependencies = [ "chrono", "deckcraft-anim", @@ -1016,7 +1073,7 @@ dependencies = [ [[package]] name = "deckcraft-fonts" -version = "0.11.1" +version = "0.12.0" dependencies = [ "epaint_default_fonts", "harfrust 0.13.3", @@ -1028,7 +1085,7 @@ dependencies = [ [[package]] name = "deckcraft-format" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-model", "serde", @@ -1039,7 +1096,7 @@ dependencies = [ [[package]] name = "deckcraft-geom" -version = "0.11.1" +version = "0.12.0" dependencies = [ "kurbo", "serde", @@ -1047,7 +1104,7 @@ dependencies = [ [[package]] name = "deckcraft-h264" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1056,7 +1113,7 @@ dependencies = [ [[package]] name = "deckcraft-hevc" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1065,21 +1122,21 @@ dependencies = [ [[package]] name = "deckcraft-isobmff" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-matroska" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-mcp" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-engine", "deckcraft-render", @@ -1089,7 +1146,7 @@ dependencies = [ [[package]] name = "deckcraft-media" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-av1", "deckcraft-h264", @@ -1107,7 +1164,7 @@ dependencies = [ [[package]] name = "deckcraft-metafile" -version = "0.11.1" +version = "0.12.0" dependencies = [ "image", "kurbo", @@ -1116,7 +1173,7 @@ dependencies = [ [[package]] name = "deckcraft-model" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-color", "deckcraft-geom", @@ -1128,15 +1185,15 @@ dependencies = [ [[package]] name = "deckcraft-ogg" -version = "0.11.1" +version = "0.12.0" [[package]] name = "deckcraft-opus" -version = "0.11.1" +version = "0.12.0" [[package]] name = "deckcraft-pdf" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-pdf", "deckcraft-fonts", @@ -1154,7 +1211,7 @@ dependencies = [ [[package]] name = "deckcraft-pptx" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-color", "deckcraft-engine", @@ -1170,7 +1227,7 @@ dependencies = [ [[package]] name = "deckcraft-render" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1189,7 +1246,7 @@ dependencies = [ [[package]] name = "deckcraft-text" -version = "0.11.1" +version = "0.12.0" dependencies = [ "chrono", "deckcraft-color", @@ -1203,8 +1260,9 @@ dependencies = [ [[package]] name = "deckcraft-ui-egui" -version = "0.11.1" +version = "0.12.0" dependencies = [ + "craft-ui", "craft-update", "deckcraft-anim", "deckcraft-color", @@ -1227,7 +1285,7 @@ dependencies = [ [[package]] name = "deckcraft-vp9" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1236,7 +1294,7 @@ dependencies = [ [[package]] name = "deckcraft-web" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-engine", "deckcraft-ui-egui", @@ -1680,6 +1738,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2250,6 +2319,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2258,11 +2344,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2274,6 +2381,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -2283,6 +2393,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2312,6 +2448,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2319,6 +2458,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2544,7 +2707,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2645,6 +2808,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3562,12 +3728,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4524,6 +4702,18 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tar" version = "0.4.46" @@ -4667,6 +4857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4920,6 +5111,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -5567,6 +5764,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -5882,11 +6090,20 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -5959,7 +6176,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.1" +version = "0.12.0" dependencies = [ "serde_json", ] @@ -6147,8 +6364,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -6157,6 +6377,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index fb54bd62..07960495 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"] [workspace.package] -version = "0.11.1" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -59,9 +59,10 @@ 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 = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-media = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } i_overlay = "9" quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } @@ -79,9 +80,11 @@ arboard = { version = "3.6", default-features = false } unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" type_complexity = "allow" self_named_constructors = "allow" large_enum_variant = "allow" @@ -105,7 +108,7 @@ codegen-units = 16 debug = 0 [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", 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" } diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index a3c03ce2..c2c38e10 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -73,7 +73,7 @@ fn main() -> ExitCode { Some("app") => report(app(rest)), Some("mcp") => report(mcp(rest)), Some("links") => { - use deckcraft_engine::links::*; + use deckcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, WEBSITE}; outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}"); ExitCode::SUCCESS } @@ -208,7 +208,7 @@ fn bench(args: &[String]) -> Result<(), String> { ("save .deckcraft", time_ms(runs, || save_bytes(&doc, "deckcraft").map(|_| ()).map_err(e))?), ("save .pptx", time_ms(runs, || save_bytes(&doc, "pptx").map(|_| ()).map_err(e))?), ("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?), - ("export .pdf", time_ms(runs.min(3), || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?), + ("export .pdf", time_ms(runs, || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?), ]; let render_total: f64 = per_slide.iter().map(|m| m.0).sum(); let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max); diff --git a/clippy.toml b/clippy.toml index 4b6c79f5..3522a15b 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,7 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true allow-indexing-slicing-in-tests = true +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] +cognitive-complexity-threshold = 30 diff --git a/crates/av1/src/header.rs b/crates/av1/src/header.rs index 85133df8..524e6d01 100644 --- a/crates/av1/src/header.rs +++ b/crates/av1/src/header.rs @@ -429,6 +429,38 @@ pub struct FrameHeader { pub spatial_id: u32, } +/// mark_ref_frames( idLen ): invalidates reference slots whose frame id is too far from the current one. +fn mark_ref_frames(st: &mut HeaderState, cur: u32, id_len: u32) { + let diff_len = st.seq.delta_frame_id_length_minus_2 + 2; + for rs in st.refs.iter_mut() { + if cur > (1 << diff_len) { + if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) { + rs.valid = false; + } + } else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) { + rs.valid = false; + } + } +} + +/// Skips buffer_removal_time for every operating point that includes this layer. +fn skip_buffer_removal_times(r: &mut BitReader, seq: &SequenceHeader, temporal_id: u32, spatial_id: u32) -> Result<()> { + if !(seq.decoder_model_info_present && r.flag()?) { + return Ok(()); + } + for op in 0..=seq.operating_points_cnt_minus_1 { + if seq.decoder_model_present_for_this_op[op] { + let idc = seq.operating_point_idc[op]; + let in_t = (idc >> temporal_id) & 1; + let in_s = (idc >> (spatial_id + 8)) & 1; + if idc == 0 || (in_t == 1 && in_s == 1) { + r.f(seq.buffer_removal_time_length_minus_1 + 1)?; + } + } + } + Ok(()) +} + impl FrameHeader { pub fn get_relative_dist(&self, seq: &SequenceHeader, a: u32, b: u32) -> i32 { relative_dist(seq, a, b) @@ -494,35 +526,10 @@ impl FrameHeader { } else { h.show_existing_frame = r.flag()?; if h.show_existing_frame { - h.frame_to_show_map_idx = r.f(3)? as usize; - if seq.decoder_model_info_present && !seq.equal_picture_interval { - r.f(seq.frame_presentation_time_length_minus_1 + 1)?; - } - if seq.frame_id_numbers_present { - r.f(id_len)?; - } - let rs = &st.refs[h.frame_to_show_map_idx]; - if !rs.valid { - return Err(Error::Invalid("show_existing_frame of an empty slot")); - } - h.frame_type = rs.frame_type; - if h.frame_type == KEY_FRAME as u8 { - h.refresh_frame_flags = all_frames; - } - if seq.film_grain_params_present { - h.film_grain = rs.grain.clone(); - } + h.parse_show_existing_frame(r, st, id_len)?; return Ok(h); } - h.frame_type = r.f(2)? as u8; - h.frame_is_intra = h.frame_type == INTRA_ONLY_FRAME as u8 || h.frame_type == KEY_FRAME as u8; - h.show_frame = r.flag()?; - if h.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval { - r.f(seq.frame_presentation_time_length_minus_1 + 1)?; - } - h.showable_frame = if h.show_frame { h.frame_type != KEY_FRAME as u8 } else { r.flag()? }; - h.error_resilient_mode = - if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { true } else { r.flag()? }; + h.parse_frame_type_and_flags(r, seq)?; } if h.frame_type == KEY_FRAME as u8 && h.show_frame { for rs in st.refs.iter_mut() { @@ -533,33 +540,11 @@ impl FrameHeader { h.order_hints[LAST_FRAME + i] = 0; } } - h.disable_cdf_update = r.flag()?; - h.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 { - r.flag()? - } else { - seq.seq_force_screen_content_tools != 0 - }; - if h.allow_screen_content_tools { - h.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 }; - } - if h.frame_is_intra { - h.force_integer_mv = true; - } + h.parse_screen_content_flags(r, seq)?; if seq.frame_id_numbers_present { h.current_frame_id = r.f(id_len)?; - // mark_ref_frames( idLen ) - let diff_len = seq.delta_frame_id_length_minus_2 + 2; - let cur = h.current_frame_id; - for rs in st.refs.iter_mut() { - if cur > (1 << diff_len) { - if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) { - rs.valid = false; - } - } else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) { - rs.valid = false; - } - } - st.current_frame_id = cur; + mark_ref_frames(st, h.current_frame_id, id_len); + st.current_frame_id = h.current_frame_id; } h.frame_size_override_flag = if h.frame_type == SWITCH_FRAME as u8 { true @@ -570,18 +555,7 @@ impl FrameHeader { }; h.order_hint = r.f(seq.order_hint_bits)?; h.primary_ref_frame = if h.frame_is_intra || h.error_resilient_mode { PRIMARY_REF_NONE } else { r.f(3)? as usize }; - if seq.decoder_model_info_present && r.flag()? { - for op in 0..=seq.operating_points_cnt_minus_1 { - if seq.decoder_model_present_for_this_op[op] { - let idc = seq.operating_point_idc[op]; - let in_t = (idc >> temporal_id) & 1; - let in_s = (idc >> (spatial_id + 8)) & 1; - if idc == 0 || (in_t == 1 && in_s == 1) { - r.f(seq.buffer_removal_time_length_minus_1 + 1)?; - } - } - } - } + skip_buffer_removal_times(r, seq, temporal_id, spatial_id)?; h.refresh_frame_flags = if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { all_frames } else { r.f(8)? }; if (!h.frame_is_intra || h.refresh_frame_flags != all_frames) && h.error_resilient_mode && seq.enable_order_hint { @@ -599,126 +573,16 @@ impl FrameHeader { h.allow_intrabc = r.flag()?; } } else { - let mut short = false; - if seq.enable_order_hint { - short = r.flag()?; - if short { - let last = r.f(3)? as usize; - let gold = r.f(3)? as usize; - h.set_frame_refs(seq, st.refs, last, gold); - } - } - for i in 0..REFS_PER_FRAME { - if !short { - h.ref_frame_idx[i] = r.f(3)? as usize; - } - if seq.frame_id_numbers_present { - r.f(seq.delta_frame_id_length_minus_2 + 2)?; - } - } - for i in 0..REFS_PER_FRAME { - if !st.refs[h.ref_frame_idx[i]].valid { - return Err(Error::Invalid("reference to an empty slot")); - } - } - if h.frame_size_override_flag && !h.error_resilient_mode { - // frame_size_with_refs( ) - let mut found = false; - for i in 0..REFS_PER_FRAME { - if r.flag()? { - let rs = &st.refs[h.ref_frame_idx[i]]; - h.upscaled_width = rs.upscaled_width; - h.frame_width = h.upscaled_width; - h.frame_height = rs.frame_height; - h.render_width = rs.render_width; - h.render_height = rs.render_height; - found = true; - break; - } - } - if !found { - h.frame_size(r, seq)?; - h.render_size(r)?; - } else { - h.superres_params(r, seq)?; - h.compute_image_size(); - } - } else { - h.frame_size(r, seq)?; - h.render_size(r)?; - } - h.allow_high_precision_mv = if h.force_integer_mv { false } else { r.flag()? }; - h.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 }; - h.is_motion_mode_switchable = r.flag()?; - h.use_ref_frame_mvs = if h.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? }; - for i in 0..REFS_PER_FRAME { - let rf = LAST_FRAME + i; - let hint = st.refs[h.ref_frame_idx[i]].order_hint; - h.order_hints[rf] = hint; - h.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, h.order_hint) > 0; - } + h.parse_inter_frame_refs(r, st)?; } h.disable_frame_end_update_cdf = if seq.reduced_still_picture_header || h.disable_cdf_update { true } else { r.flag()? }; - if h.primary_ref_frame == PRIMARY_REF_NONE { - // setup_past_independence( ) - st.seg_features = SegmentationFeatures::default(); - st.prev_gm_params = default_gm_params(); - st.lf_deltas = LoopFilterDeltas::defaults(); - h.lf.delta_enabled = true; - } else { - // load_previous( ) - let prev = &st.refs[h.ref_frame_idx[h.primary_ref_frame]]; - st.prev_gm_params = prev.gm_params; - st.lf_deltas = prev.lf_deltas; - st.seg_features = prev.seg_features; - } + h.setup_or_load_previous(st); h.prev_gm_params = st.prev_gm_params; h.tile_info(r, seq)?; h.quantization_params(r, seq)?; h.segmentation_params(r, st)?; - // delta_q_params( ) - if h.quant.base_q_idx > 0 { - h.delta_q_present = r.flag()?; - } - if h.delta_q_present { - h.delta_q_res = r.f(2)?; - } - // delta_lf_params( ) - if h.delta_q_present { - if !h.allow_intrabc { - h.delta_lf_present = r.flag()?; - } - if h.delta_lf_present { - h.delta_lf_res = r.f(2)?; - h.delta_lf_multi = r.flag()?; - } - } - h.coded_lossless = true; - for seg_id in 0..MAX_SEGMENTS { - let q = h.get_qindex(true, seg_id, 0); - let ll = q == 0 - && h.quant.delta_q_y_dc == 0 - && h.quant.delta_q_u_ac == 0 - && h.quant.delta_q_u_dc == 0 - && h.quant.delta_q_v_ac == 0 - && h.quant.delta_q_v_dc == 0; - h.lossless_array[seg_id] = ll; - if !ll { - h.coded_lossless = false; - } - if h.quant.using_qmatrix { - if ll { - h.seg_qm_level[0][seg_id] = 15; - h.seg_qm_level[1][seg_id] = 15; - h.seg_qm_level[2][seg_id] = 15; - } else { - h.seg_qm_level[0][seg_id] = h.quant.qm_y; - h.seg_qm_level[1][seg_id] = h.quant.qm_u; - h.seg_qm_level[2][seg_id] = h.quant.qm_v; - } - } - } - h.all_lossless = h.coded_lossless && h.frame_width == h.upscaled_width; + h.delta_q_lf_params(r)?; + h.compute_lossless(); h.loop_filter_params(r, seq, st)?; h.cdef_params(r, seq)?; h.lr_params(r, seq)?; @@ -739,6 +603,182 @@ impl FrameHeader { Ok(h) } + /// The show_existing_frame branch: picks the slot to show and inherits its frame type and grain. + fn parse_show_existing_frame(&mut self, r: &mut BitReader, st: &HeaderState, id_len: u32) -> Result<()> { + let seq = st.seq; + self.frame_to_show_map_idx = r.f(3)? as usize; + if seq.decoder_model_info_present && !seq.equal_picture_interval { + r.f(seq.frame_presentation_time_length_minus_1 + 1)?; + } + if seq.frame_id_numbers_present { + r.f(id_len)?; + } + let rs = &st.refs[self.frame_to_show_map_idx]; + if !rs.valid { + return Err(Error::Invalid("show_existing_frame of an empty slot")); + } + self.frame_type = rs.frame_type; + if self.frame_type == KEY_FRAME as u8 { + self.refresh_frame_flags = (1u32 << NUM_REF_FRAMES) - 1; + } + if seq.film_grain_params_present { + self.film_grain = rs.grain.clone(); + } + Ok(()) + } + + /// frame_type, show_frame, showable_frame and error_resilient_mode. + fn parse_frame_type_and_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> { + self.frame_type = r.f(2)? as u8; + self.frame_is_intra = self.frame_type == INTRA_ONLY_FRAME as u8 || self.frame_type == KEY_FRAME as u8; + self.show_frame = r.flag()?; + if self.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval { + r.f(seq.frame_presentation_time_length_minus_1 + 1)?; + } + self.showable_frame = if self.show_frame { self.frame_type != KEY_FRAME as u8 } else { r.flag()? }; + self.error_resilient_mode = + if self.frame_type == SWITCH_FRAME as u8 || (self.frame_type == KEY_FRAME as u8 && self.show_frame) { true } else { r.flag()? }; + Ok(()) + } + + /// disable_cdf_update, allow_screen_content_tools and force_integer_mv. + fn parse_screen_content_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> { + self.disable_cdf_update = r.flag()?; + self.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 { + r.flag()? + } else { + seq.seq_force_screen_content_tools != 0 + }; + if self.allow_screen_content_tools { + self.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 }; + } + if self.frame_is_intra { + self.force_integer_mv = true; + } + Ok(()) + } + + /// Reference selection, frame size, motion vector precision and filter flags of an inter frame. + fn parse_inter_frame_refs(&mut self, r: &mut BitReader, st: &HeaderState) -> Result<()> { + let seq = st.seq; + let mut short = false; + if seq.enable_order_hint { + short = r.flag()?; + if short { + let last = r.f(3)? as usize; + let gold = r.f(3)? as usize; + self.set_frame_refs(seq, st.refs, last, gold); + } + } + for i in 0..REFS_PER_FRAME { + if !short { + self.ref_frame_idx[i] = r.f(3)? as usize; + } + if seq.frame_id_numbers_present { + r.f(seq.delta_frame_id_length_minus_2 + 2)?; + } + } + for i in 0..REFS_PER_FRAME { + if !st.refs[self.ref_frame_idx[i]].valid { + return Err(Error::Invalid("reference to an empty slot")); + } + } + if self.frame_size_override_flag && !self.error_resilient_mode { + self.frame_size_with_refs(r, seq, st.refs)?; + } else { + self.frame_size(r, seq)?; + self.render_size(r)?; + } + self.allow_high_precision_mv = if self.force_integer_mv { false } else { r.flag()? }; + self.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 }; + self.is_motion_mode_switchable = r.flag()?; + self.use_ref_frame_mvs = if self.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? }; + for i in 0..REFS_PER_FRAME { + let rf = LAST_FRAME + i; + let hint = st.refs[self.ref_frame_idx[i]].order_hint; + self.order_hints[rf] = hint; + self.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, self.order_hint) > 0; + } + Ok(()) + } + + /// frame_size_with_refs( ). + fn frame_size_with_refs(&mut self, r: &mut BitReader, seq: &SequenceHeader, refs: &[RefInfo; NUM_REF_FRAMES]) -> Result<()> { + for i in 0..REFS_PER_FRAME { + if r.flag()? { + let rs = &refs[self.ref_frame_idx[i]]; + self.upscaled_width = rs.upscaled_width; + self.frame_width = self.upscaled_width; + self.frame_height = rs.frame_height; + self.render_width = rs.render_width; + self.render_height = rs.render_height; + self.superres_params(r, seq)?; + self.compute_image_size(); + return Ok(()); + } + } + self.frame_size(r, seq)?; + self.render_size(r) + } + + /// setup_past_independence( ) or load_previous( ), depending on primary_ref_frame. + fn setup_or_load_previous(&mut self, st: &mut HeaderState) { + if self.primary_ref_frame == PRIMARY_REF_NONE { + st.seg_features = SegmentationFeatures::default(); + st.prev_gm_params = default_gm_params(); + st.lf_deltas = LoopFilterDeltas::defaults(); + self.lf.delta_enabled = true; + } else { + let prev = &st.refs[self.ref_frame_idx[self.primary_ref_frame]]; + st.prev_gm_params = prev.gm_params; + st.lf_deltas = prev.lf_deltas; + st.seg_features = prev.seg_features; + } + } + + /// delta_q_params( ) and delta_lf_params( ). + fn delta_q_lf_params(&mut self, r: &mut BitReader) -> Result<()> { + if self.quant.base_q_idx > 0 { + self.delta_q_present = r.flag()?; + } + if self.delta_q_present { + self.delta_q_res = r.f(2)?; + if !self.allow_intrabc { + self.delta_lf_present = r.flag()?; + } + if self.delta_lf_present { + self.delta_lf_res = r.f(2)?; + self.delta_lf_multi = r.flag()?; + } + } + Ok(()) + } + + /// Per-segment lossless flags, quantizer matrix levels and the frame-wide lossless flags. + fn compute_lossless(&mut self) { + self.coded_lossless = true; + for seg_id in 0..MAX_SEGMENTS { + let q = self.get_qindex(true, seg_id, 0); + let ll = q == 0 + && self.quant.delta_q_y_dc == 0 + && self.quant.delta_q_u_ac == 0 + && self.quant.delta_q_u_dc == 0 + && self.quant.delta_q_v_ac == 0 + && self.quant.delta_q_v_dc == 0; + self.lossless_array[seg_id] = ll; + if !ll { + self.coded_lossless = false; + } + if self.quant.using_qmatrix { + let levels = if ll { [15; 3] } else { [self.quant.qm_y, self.quant.qm_u, self.quant.qm_v] }; + for (plane, level) in levels.into_iter().enumerate() { + self.seg_qm_level[plane][seg_id] = level; + } + } + } + self.all_lossless = self.coded_lossless && self.frame_width == self.upscaled_width; + } + fn superres_params(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> { self.use_superres = if seq.enable_superres { r.flag()? } else { false }; self.superres_denom = if self.use_superres { r.f(SUPERRES_DENOM_BITS as u32)? + SUPERRES_DENOM_MIN as u32 } else { SUPERRES_NUM as u32 }; diff --git a/crates/av1/src/inter.rs b/crates/av1/src/inter.rs index f14e59b9..ea3cdcd3 100644 --- a/crates/av1/src/inter.rs +++ b/crates/av1/src/inter.rs @@ -420,20 +420,8 @@ impl TileDecoder<'_, '_> { let fh = &self.fs.fh; for ref_list in 0..1 + is_compound as usize { let ref_frame = cand_refs[ref_list]; - let ym = self.b.y_mode; - let global = - (ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION; - let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0; let ref_idx: i32 = if self.b.use_intrabc { -1 } else { fh.ref_frame_idx[ref_frame as usize - LAST_FRAME] as i32 }; - let use_warp = if w < 8 || h < 8 || fh.force_integer_mv { - 0 - } else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid { - 1 - } else if global && !self.is_scaled_ref(ref_idx) && global_valid { - 2 - } else { - 0 - }; + let use_warp = self.warp_mode(ref_frame, ref_idx, w, h); let mv = [cand_mvs[ref_list].row as i32, cand_mvs[ref_list].col as i32]; if use_warp != 0 { let params = if use_warp == 1 { self.b.local_warp_params } else { fh.gm_params[ref_frame as usize] }; @@ -448,40 +436,7 @@ impl TileDecoder<'_, '_> { } } let ct = self.b.compound_type; - if ct == COMPOUND_WEDGE && plane == 0 { - let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign]; - for i in 0..h { - for j in 0..w { - self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32; - } - } - } else if ct == COMPOUND_INTRA { - let size_scale = MAX_SB_SIZE / h.max(w); - let im = self.b.interintra_mode; - for i in 0..h { - for j in 0..w { - self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED { - II_WEIGHTS_1D[i * size_scale] as i32 - } else if im == II_H_PRED { - II_WEIGHTS_1D[j * size_scale] as i32 - } else if im == II_SMOOTH_PRED { - II_WEIGHTS_1D[i.min(j) * size_scale] as i32 - } else { - 32 - }; - } - } - } else if ct == COMPOUND_DIFFWTD && plane == 0 { - for i in 0..h { - for j in 0..w { - let k = i * PRED_STRIDE + j; - let diff = (preds[0][k] - preds[1][k]).abs(); - let diff = round2(diff, (bd - 8) + rnd.2); - let m = (38 + diff / 16).clamp(0, 64); - self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m }; - } - } - } + self.fill_blend_mask(plane, w, h, &preds, rnd.2); let (fwd, bck) = if ct == COMPOUND_DISTANCE { self.distance_weights(cand_refs) } else { (0, 0) }; let max = (1i32 << bd) - 1; let is_inter_intra = self.b.is_inter && self.b.ref_frame[1] == INTRA_FRAME as i8; @@ -521,6 +476,64 @@ impl TileDecoder<'_, '_> { } } + /// Warp selection for one reference: 0 none, 1 local warp, 2 global warp. + fn warp_mode(&self, ref_frame: i8, ref_idx: i32, w: usize, h: usize) -> u8 { + let fh = &self.fs.fh; + let ym = self.b.y_mode; + let global = + (ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION; + let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0; + if w < 8 || h < 8 || fh.force_integer_mv { + 0 + } else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid { + 1 + } else if global && !self.is_scaled_ref(ref_idx) && global_valid { + 2 + } else { + 0 + } + } + + /// Builds the blend mask (wedge, inter-intra or difference-weighted) for the current compound type. + fn fill_blend_mask(&mut self, plane: usize, w: usize, h: usize, preds: &[Vec; 2], round_post: u32) { + let bd = self.fs.bit_depth; + let ct = self.b.compound_type; + if ct == COMPOUND_WEDGE && plane == 0 { + let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign]; + for i in 0..h { + for j in 0..w { + self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32; + } + } + } else if ct == COMPOUND_INTRA { + let size_scale = MAX_SB_SIZE / h.max(w); + let im = self.b.interintra_mode; + for i in 0..h { + for j in 0..w { + self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED { + II_WEIGHTS_1D[i * size_scale] as i32 + } else if im == II_H_PRED { + II_WEIGHTS_1D[j * size_scale] as i32 + } else if im == II_SMOOTH_PRED { + II_WEIGHTS_1D[i.min(j) * size_scale] as i32 + } else { + 32 + }; + } + } + } else if ct == COMPOUND_DIFFWTD && plane == 0 { + for i in 0..h { + for j in 0..w { + let k = i * PRED_STRIDE + j; + let diff = (preds[0][k] - preds[1][k]).abs(); + let diff = round2(diff, (bd - 8) + round_post); + let m = (38 + diff / 16).clamp(0, 64); + self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m }; + } + } + } + } + fn is_scaled_ref(&self, ref_idx: i32) -> bool { if ref_idx < 0 { return false; diff --git a/crates/av1/src/tile.rs b/crates/av1/src/tile.rs index 3c11e71f..51eb2253 100644 --- a/crates/av1/src/tile.rs +++ b/crates/av1/src/tile.rs @@ -1682,6 +1682,87 @@ impl<'a, 'f> TileDecoder<'a, 'f> { } } + /// eob_pt, eob_extra and the remaining eob bits. + fn read_eob(&mut self, tx_sz: usize, tx_sz_ctx: usize, ptype: usize, tx_class: usize) -> usize { + let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4; + let ectx = (tx_class != TX_CLASS_2D) as usize; + let eob_pt = 1 + match eob_multisize { + 0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]), + 1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]), + 2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]), + 3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]), + 4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]), + 5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]), + _ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]), + }; + let mut eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 }; + let eob_shift = eob_pt as i32 - 3; + if eob_shift >= 0 { + let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]); + if extra == 1 { + eob += 1 << eob_shift; + } + for i in 1..(eob_pt as i32 - 2).max(0) { + let sh = (eob_pt as i32 - 2).max(0) - 1 - i; + if self.sd.read_literal(1) == 1 { + eob += 1 << sh; + } + } + } + eob + } + + /// Exp-Golomb coded remainder of a coefficient above the base range (read_golomb). + fn read_golomb(&mut self) -> u32 { + let mut length = 0; + loop { + length += 1; + if self.sd.read_literal(1) == 1 || length > 32 { + break; + } + } + let mut x: u32 = 1; + for _ in (0..length - 1).rev() { + x = (x << 1) | self.sd.read_literal(1); + } + x + } + + /// Reads the signs and Golomb remainders of the first `scan.len()` (= eob) coefficients, applies + /// them to `self.quant`, and returns the culLevel and dcCategory contexts. `blk4` is (x4, y4, w4, h4). + fn read_signs_and_golomb(&mut self, plane: usize, blk4: (usize, usize, usize, usize), scan: &[u16]) -> (u32, u8) { + let (x4, y4, w4, h4) = blk4; + let ptype = (plane > 0) as usize; + let mut cul_level = 0u32; + let mut dc_category = 0u8; + for (c, &p) in scan.iter().enumerate() { + let pos = p as usize; + let sign = if self.quant[pos] != 0 { + if c == 0 { + let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4); + self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1 + } else { + self.sd.read_literal(1) == 1 + } + } else { + false + }; + if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 { + let x = self.read_golomb(); + self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32; + } + if pos == 0 && self.quant[pos] > 0 { + dc_category = if sign { 1 } else { 2 }; + } + self.quant[pos] &= 0xFFFFF; + cul_level += self.quant[pos] as u32; + if sign { + self.quant[pos] = -self.quant[pos]; + } + } + (cul_level.min(63), dc_category) + } + /// coeffs( ): returns eob. fn coeffs(&mut self, plane: usize, start_x: usize, start_y: usize, tx_sz: usize) -> usize { let x4 = start_x >> 2; @@ -1707,32 +1788,8 @@ impl<'a, 'f> TileDecoder<'a, 'f> { } self.plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4); let scan = self.get_scan(tx_sz); - let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4; let tx_class = get_tx_class(self.plane_tx_type); - let ectx = (tx_class != TX_CLASS_2D) as usize; - let eob_pt = 1 + match eob_multisize { - 0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]), - 1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]), - 2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]), - 3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]), - 4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]), - 5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]), - _ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]), - }; - eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 }; - let eob_shift = eob_pt as i32 - 3; - if eob_shift >= 0 { - let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]); - if extra == 1 { - eob += 1 << eob_shift; - } - for i in 1..(eob_pt as i32 - 2).max(0) { - let sh = (eob_pt as i32 - 2).max(0) - 1 - i; - if self.sd.read_literal(1) == 1 { - eob += 1 << sh; - } - } - } + eob = self.read_eob(tx_sz, tx_sz_ctx, ptype, tx_class); let adj = ADJUSTED_TX_SIZE[tx_sz] as usize; let bwl = TX_WIDTH_LOG2[adj] as usize; let height = TX_HEIGHT[adj] as usize; @@ -1780,45 +1837,7 @@ impl<'a, 'f> TileDecoder<'a, 'f> { lev[li] = level as u8; } self.levels = lev; - for c in 0..eob { - let pos = scan[c] as usize; - let sign = if self.quant[pos] != 0 { - if c == 0 { - let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4); - self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1 - } else { - self.sd.read_literal(1) == 1 - } - } else { - false - }; - if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 { - let mut length = 0; - loop { - length += 1; - if self.sd.read_literal(1) == 1 { - break; - } - if length > 32 { - break; - } - } - let mut x: u32 = 1; - for _ in (0..length - 1).rev() { - x = (x << 1) | self.sd.read_literal(1); - } - self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32; - } - if pos == 0 && self.quant[pos] > 0 { - dc_category = if sign { 1 } else { 2 }; - } - self.quant[pos] &= 0xFFFFF; - cul_level += self.quant[pos] as u32; - if sign { - self.quant[pos] = -self.quant[pos]; - } - } - cul_level = cul_level.min(63); + (cul_level, dc_category) = self.read_signs_and_golomb(plane, (x4, y4, w4, h4), &scan[..eob]); } for i in 0..w4 { if x4 + i < self.t.above_level[plane].len() { diff --git a/crates/engine/src/cmd/design/browse_theme_tests.rs b/crates/engine/src/cmd/design/browse_theme_tests.rs index 89136269..46d8718a 100644 --- a/crates/engine/src/cmd/design/browse_theme_tests.rs +++ b/crates/engine/src/cmd/design/browse_theme_tests.rs @@ -214,6 +214,10 @@ fn preserve_and_placeholders_edit_the_open_master_or_layout() { s.execute("edit.undo", &json!({})).unwrap(); assert!(has_footers(&s)); + master_view_closed_rejects(&mut s, blank); +} + +fn master_view_closed_rejects(s: &mut Session, blank: usize) { s.execute("view.closeMaster", &json!({})).unwrap(); let n = s.doc().unwrap().history.undo.len(); let err = s.execute("master.preserve", &json!({})).unwrap_err(); @@ -224,11 +228,15 @@ fn preserve_and_placeholders_edit_the_open_master_or_layout() { assert!(err.to_string().contains("give title or footers"), "{err}"); assert_eq!(s.doc().unwrap().history.undo.len(), n); + layout_placeholders_edit_one_undo_step(s, blank); +} + +fn layout_placeholders_edit_one_undo_step(s: &mut Session, blank: usize) { s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap(); assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank }); assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); - assert!(!has_title(&s)); - assert!(has_footers(&s)); + assert!(!has_title(s)); + assert!(has_footers(s)); let n = s.doc().unwrap().history.undo.len(); s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap(); @@ -237,19 +245,19 @@ fn preserve_and_placeholders_edit_the_open_master_or_layout() { s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step"); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); - assert!(has_title(&s)); - assert!(!has_footers(&s)); - let title = shapes(&s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); + assert!(has_title(s)); + assert!(!has_footers(s)); + let title = shapes(s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); assert_eq!(title.name, "Title 1"); assert_eq!(title.ph.unwrap().idx, 0); assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty())); s.execute("edit.undo", &json!({})).unwrap(); - assert!(!has_title(&s)); - assert!(has_footers(&s)); + assert!(!has_title(s)); + assert!(has_footers(s)); s.execute("master.placeholders", &json!({"footers": false})).unwrap(); s.execute("master.placeholders", &json!({"footers": true})).unwrap(); - let restored = shapes(&s); + let restored = shapes(s); assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date))); assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer))); let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap(); @@ -264,6 +272,10 @@ fn preserve_and_placeholders_edit_the_open_master_or_layout() { s.execute("edit.undo", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + missing_title_placeholder_is_recreated(s); +} + +fn missing_title_placeholder_is_recreated(s: &mut Session) { s.edit(|doc, _| { let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap()); m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); @@ -271,7 +283,7 @@ fn preserve_and_placeholders_edit_the_open_master_or_layout() { }) .unwrap(); s.execute("master.placeholders", &json!({"title": true})).unwrap(); - let title = shapes(&s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap(); + let title = shapes(s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap(); assert_eq!(title.name, "Title"); let box_ = title.xfrm.unwrap(); assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01); diff --git a/crates/engine/src/cmd/design/colors.rs b/crates/engine/src/cmd/design/colors.rs index 9ce4f544..ec573d88 100644 --- a/crates/engine/src/cmd/design/colors.rs +++ b/crates/engine/src/cmd/design/colors.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Arc, Result, Session, Value, bad, builtin_color_schemes, builtin_font_schemes, builtin_format_schemes, json, master_index, ok, str_param, +}; pub(super) fn colors(s: &mut Session, p: &Value) -> Result { let name_v = p.get("name"); diff --git a/crates/engine/src/cmd/design/master_view.rs b/crates/engine/src/cmd/design/master_view.rs index f72aa22d..5dfe0496 100644 --- a/crates/engine/src/cmd/design/master_view.rs +++ b/crates/engine/src/cmd/design/master_view.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json, + master_index, ok, opt_usize_param, rect_param, str_param, usize_param, +}; pub(super) fn master_view(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; diff --git a/crates/engine/src/cmd/design/slide_background.rs b/crates/engine/src/cmd/design/slide_background.rs index 39205a34..0de78e7e 100644 --- a/crates/engine/src/cmd/design/slide_background.rs +++ b/crates/engine/src/cmd/design/slide_background.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value, + f64_param, ok, usize_param, with_param, +}; pub(super) fn background(s: &mut Session, p: &Value) -> Result { if bool_or(p, "copy", false) { diff --git a/crates/engine/src/cmd/design/slide_setup.rs b/crates/engine/src/cmd/design/slide_setup.rs index 672a73aa..86d9317b 100644 --- a/crates/engine/src/cmd/design/slide_setup.rs +++ b/crates/engine/src/cmd/design/slide_setup.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, TextBody, Value, bad, bool_or, bool_param, defaults, f64_param, + json, ok, str_param, +}; pub(super) fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) { for sh in shapes { diff --git a/crates/engine/src/cmd/design/tests.rs b/crates/engine/src/cmd/design/tests.rs index 4d0b5e8a..9765fcc8 100644 --- a/crates/engine/src/cmd/design/tests.rs +++ b/crates/engine/src/cmd/design/tests.rs @@ -95,7 +95,10 @@ fn colors_dialog_apply_and_reject() { assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors"); s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); + colors_dialog_rejects(&mut s, n, &harbor); +} +fn colors_dialog_rejects(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) { let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string(); assert!(err.contains("no colour scheme `Nope`"), "{err}"); assert!(s.execute("design.colors", &json!({"name": " "})).is_err()); @@ -108,32 +111,35 @@ fn colors_dialog_apply_and_reject() { assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err()); assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err()); assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err()); - assert_eq!(scheme(&s).name, "Harbor"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); - assert_eq!(undo_len(&s), n + 1); + assert_eq!(scheme(s).name, "Harbor"); + assert_eq!(scheme(s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); + assert_eq!(undo_len(s), n + 1); + colors_dialog_custom_overrides(s, n, harbor); +} +fn colors_dialog_custom_overrides(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) { s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap(); - assert_eq!(scheme(&s).name, "Custom"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1).hex(), "112233"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2)); - assert_eq!(undo_len(&s), n + 2); + assert_eq!(scheme(s).name, "Custom"); + assert_eq!(scheme(s).get(SchemeSlot::Accent1).hex(), "112233"); + assert_eq!(scheme(s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2)); + assert_eq!(undo_len(s), n + 2); let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap(); s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap(); - assert_eq!(scheme(&s).name, "Meadow"); - assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); - assert_eq!(undo_len(&s), n + 3); + assert_eq!(scheme(s).name, "Meadow"); + assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203"); + assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); + assert_eq!(undo_len(s), n + 3); s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap(); - assert_eq!(scheme(&s).name, "Talk"); - assert_eq!(scheme(&s).get(SchemeSlot::Dk1).hex(), "ABCDEF"); - assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); + assert_eq!(scheme(s).name, "Talk"); + assert_eq!(scheme(s).get(SchemeSlot::Dk1).hex(), "ABCDEF"); + assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203"); + assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(scheme(&s).name, "Meadow"); - assert_eq!(scheme(&s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1)); + assert_eq!(scheme(s).name, "Meadow"); + assert_eq!(scheme(s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1)); } #[test] @@ -175,7 +181,10 @@ fn fonts_dialog_apply_and_reject() { assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err()); assert_eq!(font_scheme(&s), classic); assert_eq!(undo_len(&s), n + 1); + fonts_dialog_custom_overrides(&mut s); +} +fn fonts_dialog_custom_overrides(s: &mut Session) { s.edit(|doc, _| { let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts; fonts.major.ea = "EA Major".into(); @@ -184,33 +193,33 @@ fn fonts_dialog_apply_and_reject() { Ok(()) }) .unwrap(); - let marked = font_scheme(&s); - let n = undo_len(&s); + let marked = font_scheme(s); + let n = undo_len(s); s.execute("design.fonts", &json!({"major": " Lato "})).unwrap(); - let custom = font_scheme(&s); + let custom = font_scheme(s); assert_eq!(custom.name, "Custom"); assert_eq!(custom.major.latin, "Lato"); assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major"); assert_eq!(custom.major.ea, "EA Major"); assert_eq!(custom.minor.cs, "CS Minor"); assert_eq!(custom.major.scripts, marked.major.scripts); - assert_eq!(undo_len(&s), n + 1); + assert_eq!(undo_len(s), n + 1); s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap(); - assert_eq!(font_scheme(&s).name, "Custom"); - assert_eq!(font_scheme(&s).major.latin, "Lato"); - assert_eq!(font_scheme(&s).minor.latin, "Roboto"); - assert_eq!(font_scheme(&s).major.ea, "EA Major"); - assert_eq!(undo_len(&s), n + 2); + assert_eq!(font_scheme(s).name, "Custom"); + assert_eq!(font_scheme(s).major.latin, "Lato"); + assert_eq!(font_scheme(s).minor.latin, "Roboto"); + assert_eq!(font_scheme(s).major.ea, "EA Major"); + assert_eq!(undo_len(s), n + 2); s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap(); - assert_eq!(font_scheme(&s).name, "Classic"); - assert_eq!(font_scheme(&s).major.latin, "Inter"); - assert_eq!(font_scheme(&s).minor.latin, "Open Sans"); - assert_eq!(font_scheme(&s).major.ea, ""); - assert!(font_scheme(&s).major.scripts.is_empty(), "a builtin is applied before the latin overlay"); - assert_eq!(undo_len(&s), n + 3); + assert_eq!(font_scheme(s).name, "Classic"); + assert_eq!(font_scheme(s).major.latin, "Inter"); + assert_eq!(font_scheme(s).minor.latin, "Open Sans"); + assert_eq!(font_scheme(s).major.ea, ""); + assert!(font_scheme(s).major.scripts.is_empty(), "a builtin is applied before the latin overlay"); + assert_eq!(undo_len(s), n + 3); s.edit(|doc, _| { std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into(); @@ -218,16 +227,16 @@ fn fonts_dialog_apply_and_reject() { }) .unwrap(); s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap(); - assert_eq!(font_scheme(&s).name, "Talk"); - assert_eq!(font_scheme(&s).major.latin, "Inter"); - assert_eq!(font_scheme(&s).minor.latin, "Nunito Sans"); - assert_eq!(font_scheme(&s).major.ea, "Kept"); + assert_eq!(font_scheme(s).name, "Talk"); + assert_eq!(font_scheme(s).major.latin, "Inter"); + assert_eq!(font_scheme(s).minor.latin, "Nunito Sans"); + assert_eq!(font_scheme(s).major.ea, "Kept"); assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts"); s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(font_scheme(&s).name, "Classic"); - assert_eq!(font_scheme(&s).major.ea, "Kept"); - assert_eq!(font_scheme(&s).minor.latin, "Open Sans"); + assert_eq!(font_scheme(s).name, "Classic"); + assert_eq!(font_scheme(s).major.ea, "Kept"); + assert_eq!(font_scheme(s).minor.latin, "Open Sans"); } #[test] diff --git a/crates/engine/src/cmd/format/font.rs b/crates/engine/src/cmd/format/font.rs index e0b4a5b4..11af99b7 100644 --- a/crates/engine/src/cmd/format/font.rs +++ b/crates/engine/src/cmd/format/font.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or, + bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param, +}; pub(super) fn bold(s: &mut Session, p: &Value) -> Result { let v = on_param(p, current_run(s).bold); diff --git a/crates/engine/src/cmd/format/painter.rs b/crates/engine/src/cmd/format/painter.rs index 51e31028..1d324811 100644 --- a/crates/engine/src/cmd/format/painter.rs +++ b/crates/engine/src/cmd/format/painter.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Bullet, Effects, Fill, Line, ParaProps, Result, RunProps, Session, Shape, Strike, Value, bad, base64_decode, bool_or, current_para, current_run, + ed, edit_shapes, format_run, ids_param, json, ok, resolve, str_param, +}; /// Formatting picked up by the Format Painter. #[derive(Clone, Debug, Default)] diff --git a/crates/engine/src/cmd/format/paragraph.rs b/crates/engine/src/cmd/format/paragraph.rs index 244433ae..dd88f405 100644 --- a/crates/engine/src/cmd/format/paragraph.rs +++ b/crates/engine/src/cmd/format/paragraph.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align, Anchor, AutoFit, Bullet, Result, Session, Spacing, TextDir, Value, bad, bool_or, color_param, current_para, f64_or, f64_param, + format_body, format_para, json, ok, on_param, resolve, str_param, +}; pub(super) fn align_to(s: &mut Session, a: Align) -> Result { format_para(s, &move |p| p.props.align = Some(a)) diff --git a/crates/engine/src/cmd/format/ruler.rs b/crates/engine/src/cmd/format/ruler.rs index 7d0b923c..2034c0fa 100644 --- a/crates/engine/src/cmd/format/ruler.rs +++ b/crates/engine/src/cmd/format/ruler.rs @@ -1,5 +1,5 @@ use super::paragraph::{clamp_pt, finite_key, tab_align, tabs_params}; -use super::*; +use super::{Result, Session, TabStop, TextDir, Value, bad, bool_or, current_para, format_para, json, ok, resolve}; /// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops, /// and where its text area sits on the slide. diff --git a/crates/engine/src/cmd/format/text_effects.rs b/crates/engine/src/cmd/format/text_effects.rs index 953f0fe7..82a4a85a 100644 --- a/crates/engine/src/cmd/format/text_effects.rs +++ b/crates/engine/src/cmd/format/text_effects.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Line, Result, Session, Value, bad, color_param, current_run, ed, edit_shapes, f64_or, format_body, format_run, json, ok, on_param, str_param, +}; pub(super) fn text_shadow(s: &mut Session, p: &Value) -> Result { let v = on_param(p, Some(current_run(s).shadow.is_some())); diff --git a/crates/engine/src/cmd/insert/album.rs b/crates/engine/src/cmd/insert/album.rs index b06f3e90..bb2f349e 100644 --- a/crates/engine/src/cmd/insert/album.rs +++ b/crates/engine/src/cmd/insert/album.rs @@ -1,5 +1,5 @@ use super::picture::{content_type, media_bytes}; -use super::*; +use super::{NO_ID_SPACE, Result, Session, ShapeKind, Value, bad, base64_encode, json}; const ALBUM_MAX_ITEMS: usize = 48; diff --git a/crates/engine/src/cmd/insert/chart.rs b/crates/engine/src/cmd/insert/chart.rs index 41323d39..6c32ff56 100644 --- a/crates/engine/src/cmd/insert/chart.rs +++ b/crates/engine/src/cmd/insert/chart.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Chart, ChartType, Result, RunKind, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, bool_param, color_value, edit_shapes, json, + rect_param, str_param, +}; pub fn chart_type(name: &str) -> ChartType { match name.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() { diff --git a/crates/engine/src/cmd/insert/links.rs b/crates/engine/src/cmd/insert/links.rs index f0959688..3e40cb91 100644 --- a/crates/engine/src/cmd/insert/links.rs +++ b/crates/engine/src/cmd/insert/links.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Geom, Result, SchemeSlot, Session, Shape, ShapeStyle, Value, add_shape, bad, bool_or, bool_param, default_rect, edit_shapes, json, rect_param, + str_param, usize_param, +}; pub(super) fn hyperlink(s: &mut Session, p: &Value) -> Result { use deckcraft_model::text::{Action, Hyperlink}; diff --git a/crates/engine/src/cmd/insert/picture.rs b/crates/engine/src/cmd/insert/picture.rs index 74bd45da..85dafa7a 100644 --- a/crates/engine/src/cmd/insert/picture.rs +++ b/crates/engine/src/cmd/insert/picture.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + EngineError, MediaClip, NO_ID_SPACE, PictureFill, Result, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, base64_decode, default_rect, + json, rect_param, str_param, xfrm_of, +}; /// Read media bytes from `data` (base64) or `path`. pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec)> { diff --git a/crates/engine/src/cmd/insert/text_fields.rs b/crates/engine/src/cmd/insert/text_fields.rs index d19f1dd1..2a04f30b 100644 --- a/crates/engine/src/cmd/insert/text_fields.rs +++ b/crates/engine/src/cmd/insert/text_fields.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + ColorRef, Fill, Result, RunKind, SchemeSlot, Session, TextBody, Value, Xfrm, add_shape, bad, bool_or, bool_param, json, new_text_box, ok, + str_param, usize_param, +}; pub(super) fn word_art(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").unwrap_or("Your text here"); diff --git a/crates/engine/src/cmd/shape/appearance.rs b/crates/engine/src/cmd/shape/appearance.rs index b1f33e62..7adb642c 100644 --- a/crates/engine/src/cmd/shape/appearance.rs +++ b/crates/engine/src/cmd/shape/appearance.rs @@ -1,4 +1,8 @@ -use super::*; +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, usize_param, +}; pub(super) fn adjust(s: &mut Session, p: &Value) -> Result { let i = usize_param(p, "index").unwrap_or(0).min(16); diff --git a/crates/engine/src/cmd/shape/freeform.rs b/crates/engine/src/cmd/shape/freeform.rs index 336938d5..f3be4f4b 100644 --- a/crates/engine/src/cmd/shape/freeform.rs +++ b/crates/engine/src/cmd/shape/freeform.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad, bool_or, id_param, json, str_param, usize_param}; pub(super) fn sites(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?; diff --git a/crates/engine/src/cmd/shape/picture.rs b/crates/engine/src/cmd/shape/picture.rs index a49a80fb..09cf22ae 100644 --- a/crates/engine/src/cmd/shape/picture.rs +++ b/crates/engine/src/cmd/shape/picture.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{NO_ID_SPACE, Result, Session, Value, bad, bool_or, bool_param, edit_geometry, edit_shapes, f64_param, json}; pub(super) fn crop(s: &mut Session, p: &Value) -> Result { let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")]; diff --git a/crates/engine/src/cmd/shape/size.rs b/crates/engine/src/cmd/shape/size.rs index 5a04f394..af235e40 100644 --- a/crates/engine/src/cmd/shape/size.rs +++ b/crates/engine/src/cmd/shape/size.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Result, Session, Value, bad, edit_geometry, f64_param, json, str_param, targets, xfrm_of}; /// Keeps a side length in `0..=MAX_PT` so a huge value cannot exhaust memory when rendering. /// An overflow to infinity lands on the limit; NaN becomes 0. diff --git a/crates/engine/src/cmd/smartart/diagrams.rs b/crates/engine/src/cmd/smartart/diagrams.rs index c4df303f..ef4a7009 100644 --- a/crates/engine/src/cmd/smartart/diagrams.rs +++ b/crates/engine/src/cmd/smartart/diagrams.rs @@ -1,6 +1,6 @@ use super::nodes::Item; use super::paint::{accent, deco, line_shape, paint, text_shape}; -use super::*; +use super::{ColorRef, ColorTransform, Diagram, DiagramNode, Fill, Geom, Line, MAX_SHAPES, SchemeSlot, Xfrm}; /// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on /// a row of their own between the parent and its other children. `half_circle` draws each node as diff --git a/crates/engine/src/cmd/smartart/edit.rs b/crates/engine/src/cmd/smartart/edit.rs index 080a752f..77662f71 100644 --- a/crates/engine/src/cmd/smartart/edit.rs +++ b/crates/engine/src/cmd/smartart/edit.rs @@ -1,6 +1,9 @@ use super::nodes::{add, demote_node, nodes_from_lines, normalize, promote_node, remove, sync_text}; use super::paint::layout; -use super::*; +use super::{ + AutoFit, COLORS, Diagram, DiagramNode, EngineError, Geom, MAX_LEVEL, NO_ID_SPACE, Paragraph, Presentation, Result, STYLES, Session, Shape, + ShapeId, ShapeKind, TextBody, Value, Xfrm, bad, bool_or, current_shapes, is_kind, json, shape_of, str_param, targets, xfrm_of, +}; /// The SmartArt group that `id` is, or is inside. fn diagram_group(shapes: &[Shape], id: ShapeId) -> Option<&Shape> { diff --git a/crates/engine/src/cmd/smartart/layout.rs b/crates/engine/src/cmd/smartart/layout.rs index baeda540..45b033ae 100644 --- a/crates/engine/src/cmd/smartart/layout.rs +++ b/crates/engine/src/cmd/smartart/layout.rs @@ -1,752 +1,829 @@ use super::diagrams::{hierarchy, org_chart}; use super::nodes::{Item, groups}; use super::paint::{accent, deco, line_shape, paint, picture_shape, text_shape}; -use super::*; +use super::{Align, ColorRef, ColorTransform, Diagram, DiagramNode, Fill, Geom, Line, Xfrm}; + +#[derive(Clone, Copy)] +struct L<'a> { + d: &'a Diagram, + area: Xfrm, + gs: &'a [Vec], + n: usize, + nf: f64, +} + +impl L<'_> { + fn nodes(&self, g: &[usize]) -> Vec<&DiagramNode> { + g.iter().filter_map(|i| self.d.nodes.get(*i)).collect() + } + + /// A titled shape for top-level group `i`, painted in its accent. + fn titled(&self, out: &mut Vec, preset: &str, x: Xfrm, i: usize, g: &[usize]) { + let mut sh = text_shape(preset, x, &self.nodes(g), true); + paint(self.d, &mut sh, i, false); + out.push((sh, g.to_vec())); + } + + fn arrow(&self, out: &mut Vec, x: Xfrm, rot: f64, i: usize) { + let mut sh = deco("rightArrow", x, rot); + paint(self.d, &mut sh, i, true); + out.push((sh, vec![])); + } +} pub(super) fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { let gs = groups(d); - let nodes = |g: &[usize]| -> Vec<&DiagramNode> { g.iter().filter_map(|i| d.nodes.get(*i)).collect() }; let n = gs.len().max(1); - let nf = n as f64; + let l = L { d, area, gs: &gs, n, nf: n as f64 }; let mut out: Vec = vec![]; - // A titled shape for top-level group `i`, painted in its accent. - let titled = |out: &mut Vec, preset: &str, x: Xfrm, i: usize, g: &[usize]| { - let mut sh = text_shape(preset, x, &nodes(g), true); - paint(d, &mut sh, i, false); - out.push((sh, g.to_vec())); - }; - let arrow = |out: &mut Vec, x: Xfrm, rot: f64, i: usize| { - let mut sh = deco("rightArrow", x, rot); - paint(d, &mut sh, i, true); - out.push((sh, vec![])); - }; match d.kind.as_str() { "hierarchy" => return hierarchy(d, area, false), "horizontalHierarchy" => return hierarchy(d, area, true), "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => return org_chart(d, area), - "list" | "hBulletList" => { - // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. - let vertical = d.kind == "list"; - for (i, g) in gs.iter().enumerate() { - let (cell, bar_h) = if vertical { - ( - Xfrm::new(area.x, area.y + i as f64 * area.h / nf, area.w, area.h / nf * 0.92), - (area.h / nf * 0.92).min(area.h * 0.12).max(18.0), - ) - } else { - let w = area.w / nf; - (Xfrm::new(area.x + i as f64 * w + w * 0.03, area.y, w * 0.94, area.h), area.h * 0.22) - }; - let has_sub = g.len() > 1; - let bar_h = bar_h.min(cell.h); - titled(&mut out, "roundRect", Xfrm::new(cell.x, cell.y, cell.w, bar_h), i, &g[..1]); - if has_sub { - let pad = cell.w * 0.03; - let x = Xfrm::new(cell.x + pad, cell.y + bar_h, cell.w - 2.0 * pad, (cell.h - bar_h).max(1.0)); - let mut sh = text_shape("rect", x, &nodes(&g[1..]), false); - paint(d, &mut sh, i, true); - out.push((sh, g[1..].to_vec())); - } - } - } - "blockList" => { - // Basic Block List: equal blocks in rows, as square as the area allows. - let cols = ((nf * area.w / area.h.max(1.0) / 1.6).sqrt().ceil() as usize).clamp(1, n); - let rows = n.div_ceil(cols); - let (w, h) = (area.w / cols as f64, area.h / rows as f64); - let (bw, bh) = (w * 0.9, (h * 0.85).min(w * 0.9 * 0.6)); - for (i, g) in gs.iter().enumerate() { - let (c, r) = (i % cols, i / cols); - let in_row = if r + 1 == rows { n - r * cols } else { cols }; - let x0 = area.x + (area.w - in_row as f64 * w) / 2.0; - titled(&mut out, "rect", Xfrm::new(x0 + c as f64 * w + (w - bw) / 2.0, area.y + r as f64 * h + (h - bh) / 2.0, bw, bh), i, g); - } - } - "process" | "chevron" => { - let chevron = d.kind == "chevron"; - let gap = if chevron { -area.w * 0.02 } else { area.w * 0.06 }; - let w = ((area.w - gap * (nf - 1.0)) / nf).max(1.0); - let h = (area.h * 0.5).min(w * if chevron { 0.45 } else { 0.65 }); - let y = area.y + (area.h - h) / 2.0; - for (i, g) in gs.iter().enumerate() { - let x = area.x + i as f64 * (w + gap); - titled(&mut out, if chevron { "chevron" } else { "roundRect" }, Xfrm::new(x, y, w, h), i, g); - if !chevron && i + 1 < n { - let aw = gap * 0.7; - arrow(&mut out, Xfrm::new(x + w + gap * 0.15, y + h / 2.0 - aw * 0.45, aw, aw * 0.9), 0.0, i); - } - } - } - "verticalProcess" => { - let gap = area.h * 0.06; - let h = ((area.h - gap * (nf - 1.0)) / nf).max(1.0); - let w = area.w * 0.6; - let x = area.x + (area.w - w) / 2.0; - for (i, g) in gs.iter().enumerate() { - let y = area.y + i as f64 * (h + gap); - titled(&mut out, "roundRect", Xfrm::new(x, y, w, h), i, g); - if i + 1 < n { - let s = gap * 0.8; - // A right arrow turned to point down. - arrow(&mut out, Xfrm::new(area.x + area.w / 2.0 - s / 2.0, y + h + gap * 0.1, s, s), 90.0, i); - } - } - } - "stepUp" => { - // Step Up Process: each step higher than the one before. - let w = area.w / nf; - let h = area.h / (nf + 1.0) * 1.6; - for (i, g) in gs.iter().enumerate() { - let y = area.y + area.h - h - i as f64 * (area.h - h) / (nf - 1.0).max(1.0); - titled(&mut out, "rect", Xfrm::new(area.x + i as f64 * w + w * 0.04, y, w * 0.92, h), i, g); - } - } - "cycle" | "blockCycle" => { - let block = d.kind == "blockCycle"; - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dd = (r * if block { 0.75 } else { 0.8 }).min(2.0 * std::f64::consts::PI * r / nf * 0.62); - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); - let (w, h) = if block { (dd * 1.3, dd * 0.75) } else { (dd, dd) }; - titled( - &mut out, - if block { "roundRect" } else { "ellipse" }, - Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), - i, - g, - ); - if n > 1 { - // An arrow halfway to the next node, along the circle. - let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); - let s = (dd * 0.35).max(6.0); - let rot = (i as f64 + 0.5) / nf * 360.0; - arrow(&mut out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), rot, i); - } - } - } - "radial" => { - // Basic Radial: the first node in the middle, the others around it with spokes. - let Some((centre, rest)) = gs.split_first() else { return out }; - let m = rest.len().max(1) as f64; - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dc = r * 0.75; - let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / m * 0.7); - for (k, _) in rest.iter().enumerate() { - let a = (k as f64 / m * 360.0 - 90.0).to_radians(); - let (ex, ey) = (cx + (r - dd / 2.0) * a.cos(), cy + (r - dd / 2.0) * a.sin()); - out.push((line_shape((cx + dc / 2.0 * a.cos(), cy + dc / 2.0 * a.sin()), (ex, ey)), vec![])); - } - titled(&mut out, "ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0, centre); - for (k, g) in rest.iter().enumerate() { - let a = (k as f64 / m * 360.0 - 90.0).to_radians(); - titled(&mut out, "ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), k + 1, g); - } - } - "pyramid" | "invertedPyramid" => { - // One triangle cut into bands; inverted, the wide band is on top. Each band is drawn as - // its own polygon so the sides keep the triangle's slope and the text stays upright. - let inverted = d.kind == "invertedPyramid"; - let h = area.h / nf; - let pw = area.h.min(area.w) * 1.15; - let px = area.x + (area.w - pw) / 2.0; - for (i, g) in gs.iter().enumerate() { - let (a, b) = (i as f64 / nf, (i as f64 + 1.0) / nf); - let (t, bt) = if inverted { (1.0 - a, 1.0 - b) } else { (a, b) }; - let (wt, wb) = (pw * t, pw * bt); - let w = wt.max(wb).max(1.0); - let bh = h * 0.96; - let x = px + (pw - w) / 2.0; - let (it, ib) = ((w - wt) / 2.0, (w - wb) / 2.0); - let path = format!("M {it:.2} 0 L {:.2} 0 L {:.2} {bh:.2} L {ib:.2} {bh:.2} Z", w - it, w - ib); - let mut sh = text_shape("rect", Xfrm::new(x, area.y + i as f64 * h, w, bh), &nodes(g), true); - sh.geom = Geom::Custom { - paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], - }; - // Keep the text inside the sloped sides: inset it to a bit past the band's mid width. - // The tip band is a triangle: its text sits at the wide end, inset to the width there. - let tip = wt.min(wb) < 1e-6; - let inset = if tip { w * 0.22 } else { (it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0 }; - if let Some(b) = &mut sh.text { - b.body.inset_l = Some(inset); - b.body.inset_r = Some(inset); - if tip { - b.body.anchor = Some(if inverted { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); - } - } - paint(d, &mut sh, i, false); - out.push((sh, g.clone())); - } - } - "matrix" | "titledMatrix" => { - let titled_m = d.kind == "titledMatrix"; - let s = area.h.min(area.w); - let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); - let (quads, title): (&[Vec], Option<&Vec>) = - if titled_m { gs.split_first().map(|(t, r)| (r, Some(t))).unwrap_or((&[], None)) } else { (&gs, None) }; - for (i, g) in quads.iter().take(4).enumerate() { - let x = Xfrm::new(x0 + (i % 2) as f64 * s / 2.0 + 3.0, y0 + (i / 2) as f64 * s / 2.0 + 3.0, s / 2.0 - 6.0, s / 2.0 - 6.0); - titled(&mut out, "roundRect", x, i + usize::from(titled_m), g); - } - if let Some(t) = title { - let c = s * 0.42; - let mut sh = text_shape("roundRect", Xfrm::new(x0 + (s - c) / 2.0, y0 + (s - c * 0.6) / 2.0, c, c * 0.6), &nodes(t), true); - paint(d, &mut sh, 0, true); - out.push((sh, t.clone())); - } - } - "pictureList" => { - // Vertical Picture List: a picture square at the left of each row, the text beside it. - let h = area.h / nf; - let bh = h * 0.88; - for (i, g) in gs.iter().enumerate() { - let y = area.y + i as f64 * h; - let s = bh.min(area.w * 0.3); - out.push((picture_shape(d, "rect", Xfrm::new(area.x, y, s, bh), i, g[0]), vec![])); - titled(&mut out, "roundRect", Xfrm::new(area.x + s + bh * 0.08, y, (area.w - s - bh * 0.08).max(1.0), bh), i, g); - } - } - "pictureCaption" | "pictureProcess" => { - // Picture Caption List: a picture over each caption, in rows. Picture Accent Process: one - // row, arrows between the steps. - let process = d.kind == "pictureProcess"; - let cols = if process { n } else { ((nf * area.w / area.h.max(1.0) / 1.2).sqrt().ceil() as usize).clamp(1, n) }; - let rows = n.div_ceil(cols); - let gap = if process { area.w * 0.05 } else { 0.0 }; - let w = (area.w - gap * (cols as f64 - 1.0)) / cols as f64; - let h = area.h / rows as f64; - let bw = if process { w } else { w * 0.9 }; - let ch = (h * 0.9).min(bw * 1.1); - for (i, g) in gs.iter().enumerate() { - let (c, r) = (i % cols, i / cols); - let in_row = if r + 1 == rows { n - r * cols } else { cols }; - let x0 = area.x + (area.w - in_row as f64 * w - gap * (in_row as f64 - 1.0)) / 2.0; - let x = x0 + c as f64 * (w + gap) + (w - bw) / 2.0; - let y = area.y + r as f64 * h + (h - ch) / 2.0; - let ph = ch * 0.62; - out.push(( - picture_shape(d, if process { "ellipse" } else { "rect" }, Xfrm::new(x + (bw - ph.min(bw)) / 2.0, y, ph.min(bw), ph), i, g[0]), - vec![], - )); - titled(&mut out, if process { "roundRect" } else { "rect" }, Xfrm::new(x, y + ph + ch * 0.03, bw, ch * 0.35), i, g); - if process && i + 1 < n { - let a = (gap * 0.7).min(ch * 0.2); - arrow(&mut out, Xfrm::new(x + bw + (gap - a) / 2.0, y + ph / 2.0 - a / 2.0, a, a), 0.0, i); - } - } - } - "textCycle" | "continuousCycle" => { - // Text Cycle: the text alone around a circle, arrows between. Continuous Cycle: boxes on - // a ring. - let text_only = d.kind == "textCycle"; - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dd = (r * 0.8).min(2.0 * std::f64::consts::PI * r / nf * 0.62); - if !text_only { - let ring = r * 2.0 + dd * 0.3; - let mut sh = deco("donut", Xfrm::new(cx - ring / 2.0, cy - ring / 2.0, ring, ring), 0.0); - sh.geom = Geom::Preset { name: "donut".into(), adj: vec![7000.0] }; - paint(d, &mut sh, 0, true); - out.push((sh, vec![])); - } - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); - let (w, h) = (dd * 1.35, dd * 0.7); - let mut sh = text_shape("roundRect", Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), &nodes(g), true); - paint(d, &mut sh, i, false); - if text_only { - sh.fill = Some(Fill::None); - sh.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - sh.effects = None; - let c = ColorRef::scheme(accent(d, i)).with(ColorTransform::LumMod(75_000)); - if let Some(b) = &mut sh.text { - for p in &mut b.paragraphs { - for r in &mut p.runs { - r.props.fill = Some(Fill::solid(c.clone())); - } - } - } - if n > 1 { - let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); - let s = (dd * 0.3).max(6.0); - arrow( - &mut out, - Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), - (i as f64 + 0.5) / nf * 360.0, - i, - ); - } - } - out.push((sh, g.clone())); - } - } - "segmentedCycle" | "pie" => { - // Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges. - // The text sits in a clear box over each segment's middle. - let ring = d.kind == "segmentedCycle"; - let s = area.h.min(area.w); - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let gap = if ring { 2.0 } else { 0.0 }; - for (i, g) in gs.iter().enumerate() { - // Angles clockwise from 3 o'clock, starting at the top. - let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0; - let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0; - let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0); - let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round(); - // blockArc runs from its first angle to its second clockwise; so does pie. - sh.geom = Geom::Preset { - name: if ring { "blockArc" } else { "pie" }.into(), - adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] }, - }; - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - let m = ((a0 + a1) / 2.0).to_radians(); - let rr = if ring { s * 0.4375 } else { s * 0.3 }; - let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9); - let th = s * 0.16; - let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &nodes(g), true); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g.clone())); - } - } - "multidirectionalCycle" => { - // Multidirectional Cycle: nodes around a circle, double-headed arrows between them. - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55); - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); - titled(&mut out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g); - if n > 1 { - let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); - let s = (dd * 0.5).max(8.0); - let mut sh = deco( - "leftRightArrow", - Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4), - (i as f64 + 0.5) / nf * 360.0, - ); - paint(d, &mut sh, i, true); - out.push((sh, vec![])); - } - } - } - "cycleMatrix" => { - // Cycle Matrix: four quarters of a circle in the middle with the top-level text, each - // quarter's sub-points in a box at its corner. - let s = area.h.min(area.w); - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let c = s * 0.56; - for (i, g) in gs.iter().take(4).enumerate() { - let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); - let b = s * 0.4; - let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b); - if g.len() > 1 { - let mut sh = text_shape("roundRect", bx, &nodes(&g[1..]), false); - paint(d, &mut sh, i, true); - if let Some(body) = &mut sh.text { - // Text in the box's outer part, clear of the circle. - body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 }); - body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 }); - body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); - } - out.push((sh, g[1..].to_vec())); - } - } - for (i, g) in gs.iter().take(4).enumerate() { - let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); - let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5; - let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0); - sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] }; - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - let q = c * 0.3; - let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &nodes(&g[..1]), true); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g[..1].to_vec())); - } - } - "segmentedPyramid" => { - // Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, โ€ฆ), one per node, - // every other one upside down. - let rows = (nf.sqrt().ceil() as usize).max(1); - let s = area.h.min(area.w / 1.15); - let pw = s * 1.15; - let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0); - let (tw, th) = (pw / rows as f64, s / rows as f64); - let mut k = 0; - 'rows: for r in 0..rows { - let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0; - for c in 0..(2 * r + 1) { - let Some(g) = gs.get(k) else { break 'rows }; - let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th); - let mut sh = text_shape("rect", x, &nodes(g), true); - // Drawn as a polygon (not a flipped preset) so upside-down text stays upright. - let path = if c % 2 == 1 { - format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0) - } else { - format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0) - }; - sh.geom = Geom::Custom { - paths: vec![deckcraft_model::CustomPath { - w: tw, - h: th, - d: path, - fill: deckcraft_geom::preset::FillMode::Norm, - stroke: true, - }], - }; - if let Some(b) = &mut sh.text { - // The text in the triangle's wide half. - b.body.inset_l = Some(tw * 0.22); - b.body.inset_r = Some(tw * 0.22); - if c % 2 == 1 { - b.body.anchor = Some(deckcraft_model::text::Anchor::Top); - b.body.inset_b = Some(th * 0.35); - } else { - b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom); - b.body.inset_t = Some(th * 0.35); - } - } - paint(d, &mut sh, k, false); - out.push((sh, g.clone())); - k += 1; - } - } - } - "verticalBoxList" => { - // Vertical Box List: a pale box per node with its sub-points, its title on a tab - // overlapping the box's top left. - let h = area.h / nf; - for (i, g) in gs.iter().enumerate() { - let y = area.y + i as f64 * h; - let tab_h = (h * 0.3).min(40.0); - let mut bx = text_shape("rect", Xfrm::new(area.x, y + tab_h / 2.0, area.w, h * 0.9 - tab_h / 2.0), &nodes(&g[1..]), false); - paint(d, &mut bx, i, true); - if let Some(b) = &mut bx.text { - b.body.inset_t = Some(tab_h * 0.6); - } - out.push((bx, g[1..].to_vec())); - titled(&mut out, "roundRect", Xfrm::new(area.x + area.w * 0.05, y, area.w * 0.6, tab_h), i, &g[..1]); - } - } - "trapezoidList" | "tableList" => { - // Trapezoid List: a column per node, each a trapezoid with its sub-points. Table List: the - // first node as a header bar over a column per other node. - let table = d.kind == "tableList"; - let (head, cols): (Option<&Vec>, &[Vec]) = - if table { gs.split_first().map(|(h, r)| (Some(h), r)).unwrap_or((None, &[])) } else { (None, &gs) }; - let top = if head.is_some() { area.h * 0.28 } else { 0.0 }; - if let Some(h) = head { - titled(&mut out, "rect", Xfrm::new(area.x, area.y, area.w, top * 0.9), 0, h); - } - let m = cols.len().max(1) as f64; - let w = area.w / m; - for (k, g) in cols.iter().enumerate() { - let i = k + usize::from(table); - let x = Xfrm::new(area.x + k as f64 * w + w * 0.02, area.y + top, w * 0.96, area.h - top); - let mut sh = text_shape(if table { "rect" } else { "trapezoid" }, x, &nodes(g), true); - if let Some(b) = &mut sh.text { - b.body.anchor = Some(deckcraft_model::text::Anchor::Top); - if !table { - b.body.inset_l = Some(x.w * 0.2); - b.body.inset_r = Some(x.w * 0.2); - b.body.inset_t = Some(x.h * 0.08); - } - } - paint(d, &mut sh, i, table); - out.push((sh, g.clone())); - } - } - "accentProcess" => { - // Accent Process: a title box per step with its sub-points in a pale box below and to the - // right, arrows between the titles. - let gap = area.w * 0.05; - let w = (area.w - gap * (nf - 1.0)) / nf; - for (i, g) in gs.iter().enumerate() { - let x = area.x + i as f64 * (w + gap); - let th = area.h * 0.22; - if g.len() > 1 { - let mut sh = text_shape("rect", Xfrm::new(x + w * 0.12, area.y + th * 0.7, w * 0.88, area.h - th * 0.7), &nodes(&g[1..]), false); - paint(d, &mut sh, i, true); - if let Some(b) = &mut sh.text { - b.body.inset_t = Some(th * 0.45); - } - out.push((sh, g[1..].to_vec())); - } - titled(&mut out, "roundRect", Xfrm::new(x, area.y, w * 0.85, th), i, &g[..1]); - if i + 1 < n { - let a = gap * 0.8; - arrow(&mut out, Xfrm::new(x + w + (gap - a) / 2.0 - w * 0.07, area.y + th / 2.0 - a / 2.0, a, a), 0.0, i); - } - } - } - "continuousBlockProcess" => { - // Continuous Block Process: one big pale arrow behind a row of boxes. - let mut big = deco("rightArrow", Xfrm::new(area.x, area.y + area.h * 0.15, area.w, area.h * 0.7), 0.0); - paint(d, &mut big, 0, true); - out.push((big, vec![])); - let w = area.w * 0.85 / nf; - let h = area.h * 0.32; - for (i, g) in gs.iter().enumerate() { - let x = area.x + area.w * 0.02 + i as f64 * w; - titled(&mut out, "roundRect", Xfrm::new(x + w * 0.05, area.y + (area.h - h) / 2.0, w * 0.9, h), i, g); - } - } - "bendingProcess" => { - // Basic Bending Process: rows of boxes read left to right, wrapping, arrows between. - let cols = ((nf * area.w / area.h.max(1.0) / 1.5).sqrt().ceil() as usize).clamp(1, n); - let rows = n.div_ceil(cols); - let (cw, rh) = (area.w / cols as f64, area.h / rows as f64); - let (bw, bh) = (cw * 0.72, (rh * 0.75).min(cw * 0.72 * 0.6)); - for (i, g) in gs.iter().enumerate() { - let (c, r) = (i % cols, i / cols); - let x = area.x + c as f64 * cw; - let y = area.y + r as f64 * rh + (rh - bh) / 2.0; - titled(&mut out, "rect", Xfrm::new(x, y, bw, bh), i, g); - if i + 1 < n { - let a = (cw - bw) * 0.7; - if c + 1 < cols { - arrow(&mut out, Xfrm::new(x + bw + (cw - bw - a) / 2.0, y + bh / 2.0 - a / 2.0, a, a), 0.0, i); - } else { - // The bend: down from the row's last box towards the next row's first. - let a = (rh - bh).min(a * 1.5).max(6.0); - arrow(&mut out, Xfrm::new(x + bw / 2.0 - a / 2.0, y + bh + (rh - bh - a) / 2.0, a, a), 90.0, i); - } - } - } - } - "linearVenn" | "stackedVenn" => { - // Linear Venn: overlapping circles in a row. Stacked Venn: circles nested from the bottom, - // the text near each one's top. - let linear = d.kind == "linearVenn"; - if linear { - let dc = (area.w / (nf * 0.75 + 0.25)).min(area.h); - let step = dc * 0.75; - let x0 = area.x + (area.w - (step * (nf - 1.0) + dc)) / 2.0; - for (i, g) in gs.iter().enumerate() { - let mut sh = text_shape("ellipse", Xfrm::new(x0 + i as f64 * step, area.y + (area.h - dc) / 2.0, dc, dc), &nodes(g), true); - paint(d, &mut sh, i, false); - if let Some(Fill::Solid { color: c }) = &mut sh.fill { - *c = c.clone().with(ColorTransform::Alpha(55_000)); - } - out.push((sh, g.clone())); - } - } else { - let s = area.h.min(area.w); - let (cx, bottom) = (area.x + area.w / 2.0, area.y + (area.h + s) / 2.0); - for (i, _) in gs.iter().enumerate() { - let dc = s * (1.0 - i as f64 / nf); - let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, bottom - dc, dc, dc), 0.0); - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - } - // Each circle's text in the band between its top and the next circle's. - let band = s / nf; - for (i, g) in gs.iter().enumerate() { - let dc = s * (1.0 - i as f64 / nf); - let top = bottom - dc; - let h = if i + 1 < n { band } else { dc }; - let w = dc * if i + 1 < n { 0.6 } else { 0.7 }; - let mut t = - text_shape("rect", Xfrm::new(cx - w / 2.0, top + if i + 1 < n { band * 0.05 } else { 0.0 }, w, h * 0.9), &nodes(g), true); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g.clone())); - } - } - } - "nestedTarget" => { - // Nested Target: rounded boxes nested inside each other, the text on each one's top band. - let band_x = area.w / 2.0 / nf * 0.6; - let band_y = area.h / nf * 0.9 / 2.0; - for (i, _) in gs.iter().enumerate() { - let (dx, dy) = (band_x * i as f64, band_y * i as f64); - let mut sh = deco("roundRect", Xfrm::new(area.x + dx, area.y + dy, area.w - 2.0 * dx, area.h - 2.0 * dy), 0.0); - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - } - for (i, g) in gs.iter().enumerate() { - let (dx, dy) = (band_x * i as f64, band_y * i as f64); - let h = if i + 1 < n { band_y } else { area.h - 2.0 * dy }; - let mut t = text_shape( - "rect", - Xfrm::new(area.x + dx + band_x * 0.3, area.y + dy, (area.w - 2.0 * dx - band_x * 0.6).max(1.0), h), - &nodes(g), - true, - ); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g.clone())); - } - } - "gridMatrix" => { - // Grid Matrix: four quadrants on crossed axes. - let s = area.h.min(area.w); - let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); - let t = s * 0.06; - for (preset, x) in [("leftRightArrow", Xfrm::new(x0, y0 + (s - t) / 2.0, s, t)), ("upDownArrow", Xfrm::new(x0 + (s - t) / 2.0, y0, t, s))] - { - let mut sh = deco(preset, x, 0.0); - paint(d, &mut sh, 0, true); - out.push((sh, vec![])); - } - let q = s / 2.0 - t * 1.2; - for (i, g) in gs.iter().take(4).enumerate() { - let (c, r) = ((i % 2) as f64, (i / 2) as f64); - let x = Xfrm::new(x0 + c * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - c), y0 + r * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - r), q, q); - titled(&mut out, "rect", x, i, g); - } - } - "gear" => { - // Gear: up to three interlocking gears, the first the biggest. - let s = area.h.min(area.w * 0.62); - let (x0, y0) = (area.x + (area.w - s / 0.62) / 2.0, area.y + (area.h - s) / 2.0); - let spots = [(0.0, 0.18, 0.66, "gear9"), (0.5, 0.0, 0.48, "gear6"), (0.56, 0.48, 0.42, "gear6")]; - for (i, g) in gs.iter().take(3).enumerate() { - let (fx, fy, fs, preset) = spots[i]; - let z = s * fs; - titled(&mut out, preset, Xfrm::new(x0 + fx * s * 1.3, y0 + fy * s, z, z), i, g); - } - } - "funnel" => { - // Funnel: the ideas poured in at the top (up to three), the last one coming out below. - let s = area.h.min(area.w); - let (cx, y0) = (area.x + area.w / 2.0, area.y); - let fw = s * 0.8; - let fh = s * 0.62; - let mut f = deco("funnel", Xfrm::new(cx - fw / 2.0, y0, fw, fh), 0.0); - paint(d, &mut f, 0, true); - out.push((f, vec![])); - let (inside, last) = match gs.split_last() { - Some((l, r)) if !r.is_empty() => (r, Some(l)), - _ => (&gs[..], None), - }; - let k = inside.len().min(3); - let dc = fw * 0.27; - let spots = [(0.0, 0.04), (-0.2, 0.3), (0.2, 0.3)]; - for (i, g) in inside.iter().take(3).enumerate() { - let (dx, dy) = if k == 1 { (0.0, 0.15) } else { spots[i] }; - titled(&mut out, "ellipse", Xfrm::new(cx + dx * fw - dc / 2.0, y0 + dy * fh + fh * 0.05, dc, dc), i, g); - } - if let Some(g) = last { - let a = s * 0.12; - let mut ar = deco("downArrow", Xfrm::new(cx - a / 2.0, y0 + fh, a, a * 1.2), 0.0); - paint(d, &mut ar, k, true); - out.push((ar, vec![])); - titled(&mut out, "roundRect", Xfrm::new(cx - fw * 0.35, y0 + fh + a * 1.3, fw * 0.7, (s - fh - a * 1.3).max(10.0)), k, g); - } - } - "pictureHList" => { - // Horizontal Picture List: a column per node, a big picture over its text. - let w = area.w / nf; - for (i, g) in gs.iter().enumerate() { - let x = area.x + i as f64 * w + w * 0.04; - let bw = w * 0.92; - let ph = area.h * 0.55; - out.push((picture_shape(d, "rect", Xfrm::new(x, area.y, bw, ph), i, g[0]), vec![])); - let mut sh = text_shape("rect", Xfrm::new(x, area.y + ph, bw, area.h - ph), &nodes(g), true); - if let Some(b) = &mut sh.text { - b.body.anchor = Some(deckcraft_model::text::Anchor::Top); - } - paint(d, &mut sh, i, false); - out.push((sh, g.clone())); - } - } - "timeline" => { - // Basic Timeline: an arrow across the middle, a dot per node on it, the text above and - // below in turn. - let mid = area.y + area.h / 2.0; - let bar = (area.h * 0.08).max(6.0); - let mut sh = deco("rightArrow", Xfrm::new(area.x, mid - bar, area.w, bar * 2.0), 0.0); - paint(d, &mut sh, 0, true); - out.push((sh, vec![])); - let w = area.w * 0.92 / nf; - for (i, g) in gs.iter().enumerate() { - let cx = area.x + w * (i as f64 + 0.5); - let dot = bar * 1.4; - let mut dsh = deco("ellipse", Xfrm::new(cx - dot / 2.0, mid - dot / 2.0, dot, dot), 0.0); - paint(d, &mut dsh, i, false); - out.push((dsh, vec![])); - let th = area.h / 2.0 - bar * 1.6; - let y = if i % 2 == 0 { area.y } else { mid + bar * 1.6 }; - let mut t = text_shape("rect", Xfrm::new(cx - w * 0.48, y, w * 0.96, th.max(1.0)), &nodes(g), true); - if let Some(b) = &mut t.text { - b.body.anchor = Some(if i % 2 == 0 { deckcraft_model::text::Anchor::Bottom } else { deckcraft_model::text::Anchor::Top }); - } - paint(d, &mut t, i, true); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - out.push((t, g.clone())); - } - } - "venn" => { - // Basic Venn: see-through circles overlapping in a ring (two or three in a row/triangle). - let s = area.h.min(area.w); - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dc = if n == 1 { s } else { s * 0.62 }; - let r = if n == 1 { 0.0 } else { (s - dc) / 2.0 }; - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 + if n == 2 { 180.0 } else { -90.0 }).to_radians(); - let mut sh = text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dc / 2.0, cy + r * a.sin() - dc / 2.0, dc, dc), &nodes(g), true); - paint(d, &mut sh, i, false); - if let Some(Fill::Solid { color: c }) = &mut sh.fill { - *c = c.clone().with(ColorTransform::Alpha(50_000)); - } - if let Some(b) = &mut sh.text { - // Text towards the circle's outer side, clear of the overlap. - let p = dc * 0.36; - let (dx, dy) = (a.cos() * p, a.sin() * p); - b.body.inset_l = Some((7.2 + dx).max(2.0)); - b.body.inset_r = Some((7.2 - dx).max(2.0)); - b.body.inset_t = Some((3.6 + dy).max(2.0)); - b.body.inset_b = Some((3.6 - dy).max(2.0)); - } - out.push((sh, g.clone())); - } - } - "target" => { - // Basic Target: nested rings, the outermost first, each ring's text in a box beside it - // with a line from the ring. - let s = area.h.min(area.w * 0.5); - let (cx, cy) = (area.x + s / 2.0, area.y + area.h / 2.0); - let band = s / 2.0 / nf; - for (i, _) in gs.iter().enumerate() { - let dc = s - 2.0 * band * i as f64; - let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0.0); - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - } - let tx = area.x + s + area.w * 0.06; - let rh = area.h / nf; - for (i, g) in gs.iter().enumerate() { - let ty = area.y + rh * (i as f64 + 0.5); - // The point on ring i's band level with the text, else its top. - let ry = cy - s / 2.0 + band * (i as f64 + 0.5); - out.push((line_shape((cx, ry.min(ty)), (tx, ty)), vec![])); - let mut t = text_shape("rect", Xfrm::new(tx, ty - rh * 0.45, (area.x + area.w - tx).max(1.0), rh * 0.9), &nodes(g), true); - paint(d, &mut t, i, true); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - if let Some(b) = &mut t.text { - for p in &mut b.paragraphs { - p.props.align = Some(Align::Left); - } - } - out.push((t, g.clone())); - } - } + "list" | "hBulletList" => arm_list(&l, &mut out), + "blockList" => arm_block_list(&l, &mut out), + "process" | "chevron" => arm_process(&l, &mut out), + "verticalProcess" => arm_vertical_process(&l, &mut out), + "stepUp" => arm_step_up(&l, &mut out), + "cycle" | "blockCycle" => arm_cycle(&l, &mut out), + "radial" => arm_radial(&l, &mut out), + "pyramid" | "invertedPyramid" => arm_pyramid(&l, &mut out), + "matrix" | "titledMatrix" => arm_matrix(&l, &mut out), + "pictureList" => arm_picture_list(&l, &mut out), + "pictureCaption" | "pictureProcess" => arm_picture_caption(&l, &mut out), + "textCycle" | "continuousCycle" => arm_text_cycle(&l, &mut out), + "segmentedCycle" | "pie" => arm_segmented_cycle(&l, &mut out), + "multidirectionalCycle" => arm_multidirectional_cycle(&l, &mut out), + "cycleMatrix" => arm_cycle_matrix(&l, &mut out), + "segmentedPyramid" => arm_segmented_pyramid(&l, &mut out), + "verticalBoxList" => arm_vertical_box_list(&l, &mut out), + "trapezoidList" | "tableList" => arm_trapezoid_list(&l, &mut out), + "accentProcess" => arm_accent_process(&l, &mut out), + "continuousBlockProcess" => arm_continuous_block_process(&l, &mut out), + "bendingProcess" => arm_bending_process(&l, &mut out), + "linearVenn" | "stackedVenn" => arm_linear_venn(&l, &mut out), + "nestedTarget" => arm_nested_target(&l, &mut out), + "gridMatrix" => arm_grid_matrix(&l, &mut out), + "gear" => arm_gear(&l, &mut out), + "funnel" => arm_funnel(&l, &mut out), + "pictureHList" => arm_picture_h_list(&l, &mut out), + "timeline" => arm_timeline(&l, &mut out), + "venn" => arm_venn(&l, &mut out), + "target" => arm_target(&l, &mut out), _ => {} } out } + +fn arm_list(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. + let vertical = d.kind == "list"; + for (i, g) in gs.iter().enumerate() { + let (cell, bar_h) = if vertical { + (Xfrm::new(area.x, area.y + i as f64 * area.h / nf, area.w, area.h / nf * 0.92), (area.h / nf * 0.92).min(area.h * 0.12).max(18.0)) + } else { + let w = area.w / nf; + (Xfrm::new(area.x + i as f64 * w + w * 0.03, area.y, w * 0.94, area.h), area.h * 0.22) + }; + let has_sub = g.len() > 1; + let bar_h = bar_h.min(cell.h); + l.titled(out, "roundRect", Xfrm::new(cell.x, cell.y, cell.w, bar_h), i, &g[..1]); + if has_sub { + let pad = cell.w * 0.03; + let x = Xfrm::new(cell.x + pad, cell.y + bar_h, cell.w - 2.0 * pad, (cell.h - bar_h).max(1.0)); + let mut sh = text_shape("rect", x, &l.nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + out.push((sh, g[1..].to_vec())); + } + } +} + +fn arm_block_list(l: &L, out: &mut Vec) { + let L { area, gs, n, nf, .. } = *l; + // Basic Block List: equal blocks in rows, as square as the area allows. + let cols = ((nf * area.w / area.h.max(1.0) / 1.6).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols); + let (w, h) = (area.w / cols as f64, area.h / rows as f64); + let (bw, bh) = (w * 0.9, (h * 0.85).min(w * 0.9 * 0.6)); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let in_row = if r + 1 == rows { n - r * cols } else { cols }; + let x0 = area.x + (area.w - in_row as f64 * w) / 2.0; + l.titled(out, "rect", Xfrm::new(x0 + c as f64 * w + (w - bw) / 2.0, area.y + r as f64 * h + (h - bh) / 2.0, bw, bh), i, g); + } +} + +fn arm_process(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + let chevron = d.kind == "chevron"; + let gap = if chevron { -area.w * 0.02 } else { area.w * 0.06 }; + let w = ((area.w - gap * (nf - 1.0)) / nf).max(1.0); + let h = (area.h * 0.5).min(w * if chevron { 0.45 } else { 0.65 }); + let y = area.y + (area.h - h) / 2.0; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * (w + gap); + l.titled(out, if chevron { "chevron" } else { "roundRect" }, Xfrm::new(x, y, w, h), i, g); + if !chevron && i + 1 < n { + let aw = gap * 0.7; + l.arrow(out, Xfrm::new(x + w + gap * 0.15, y + h / 2.0 - aw * 0.45, aw, aw * 0.9), 0.0, i); + } + } +} + +fn arm_vertical_process(l: &L, out: &mut Vec) { + let L { area, gs, n, nf, .. } = *l; + let gap = area.h * 0.06; + let h = ((area.h - gap * (nf - 1.0)) / nf).max(1.0); + let w = area.w * 0.6; + let x = area.x + (area.w - w) / 2.0; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * (h + gap); + l.titled(out, "roundRect", Xfrm::new(x, y, w, h), i, g); + if i + 1 < n { + let s = gap * 0.8; + // A right arrow turned to point down. + l.arrow(out, Xfrm::new(area.x + area.w / 2.0 - s / 2.0, y + h + gap * 0.1, s, s), 90.0, i); + } + } +} + +fn arm_step_up(l: &L, out: &mut Vec) { + let L { area, gs, nf, .. } = *l; + // Step Up Process: each step higher than the one before. + let w = area.w / nf; + let h = area.h / (nf + 1.0) * 1.6; + for (i, g) in gs.iter().enumerate() { + let y = area.y + area.h - h - i as f64 * (area.h - h) / (nf - 1.0).max(1.0); + l.titled(out, "rect", Xfrm::new(area.x + i as f64 * w + w * 0.04, y, w * 0.92, h), i, g); + } +} + +fn arm_cycle(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + let block = d.kind == "blockCycle"; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * if block { 0.75 } else { 0.8 }).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = if block { (dd * 1.3, dd * 0.75) } else { (dd, dd) }; + l.titled(out, if block { "roundRect" } else { "ellipse" }, Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), i, g); + if n > 1 { + // An arrow halfway to the next node, along the circle. + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.35).max(6.0); + let rot = (i as f64 + 0.5) / nf * 360.0; + l.arrow(out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), rot, i); + } + } +} + +fn arm_radial(l: &L, out: &mut Vec) { + let L { area, gs, .. } = *l; + // Basic Radial: the first node in the middle, the others around it with spokes. + let Some((centre, rest)) = gs.split_first() else { return }; + let m = rest.len().max(1) as f64; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = r * 0.75; + let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / m * 0.7); + for (k, _) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + let (ex, ey) = (cx + (r - dd / 2.0) * a.cos(), cy + (r - dd / 2.0) * a.sin()); + out.push((line_shape((cx + dc / 2.0 * a.cos(), cy + dc / 2.0 * a.sin()), (ex, ey)), vec![])); + } + l.titled(out, "ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0, centre); + for (k, g) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + l.titled(out, "ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), k + 1, g); + } +} + +fn arm_pyramid(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // One triangle cut into bands; inverted, the wide band is on top. Each band is drawn as + // its own polygon so the sides keep the triangle's slope and the text stays upright. + let inverted = d.kind == "invertedPyramid"; + let h = area.h / nf; + let pw = area.h.min(area.w) * 1.15; + let px = area.x + (area.w - pw) / 2.0; + for (i, g) in gs.iter().enumerate() { + let (a, b) = (i as f64 / nf, (i as f64 + 1.0) / nf); + let (t, bt) = if inverted { (1.0 - a, 1.0 - b) } else { (a, b) }; + let (wt, wb) = (pw * t, pw * bt); + let w = wt.max(wb).max(1.0); + let bh = h * 0.96; + let x = px + (pw - w) / 2.0; + let (it, ib) = ((w - wt) / 2.0, (w - wb) / 2.0); + let path = format!("M {it:.2} 0 L {:.2} 0 L {:.2} {bh:.2} L {ib:.2} {bh:.2} Z", w - it, w - ib); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + i as f64 * h, w, bh), &l.nodes(g), true); + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], + }; + // Keep the text inside the sloped sides: inset it to a bit past the band's mid width. + // The tip band is a triangle: its text sits at the wide end, inset to the width there. + let tip = wt.min(wb) < 1e-6; + let inset = if tip { w * 0.22 } else { (it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0 }; + if let Some(b) = &mut sh.text { + b.body.inset_l = Some(inset); + b.body.inset_r = Some(inset); + if tip { + b.body.anchor = Some(if inverted { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } + } + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); + } +} + +fn arm_matrix(l: &L, out: &mut Vec) { + let L { d, area, gs, .. } = *l; + let titled_m = d.kind == "titledMatrix"; + let s = area.h.min(area.w); + let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); + let (quads, title): (&[Vec], Option<&Vec>) = + if titled_m { gs.split_first().map(|(t, r)| (r, Some(t))).unwrap_or((&[], None)) } else { (gs, None) }; + for (i, g) in quads.iter().take(4).enumerate() { + let x = Xfrm::new(x0 + (i % 2) as f64 * s / 2.0 + 3.0, y0 + (i / 2) as f64 * s / 2.0 + 3.0, s / 2.0 - 6.0, s / 2.0 - 6.0); + l.titled(out, "roundRect", x, i + usize::from(titled_m), g); + } + if let Some(t) = title { + let c = s * 0.42; + let mut sh = text_shape("roundRect", Xfrm::new(x0 + (s - c) / 2.0, y0 + (s - c * 0.6) / 2.0, c, c * 0.6), &l.nodes(t), true); + paint(d, &mut sh, 0, true); + out.push((sh, t.clone())); + } +} + +fn arm_picture_list(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Vertical Picture List: a picture square at the left of each row, the text beside it. + let h = area.h / nf; + let bh = h * 0.88; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * h; + let s = bh.min(area.w * 0.3); + out.push((picture_shape(d, "rect", Xfrm::new(area.x, y, s, bh), i, g[0]), vec![])); + l.titled(out, "roundRect", Xfrm::new(area.x + s + bh * 0.08, y, (area.w - s - bh * 0.08).max(1.0), bh), i, g); + } +} + +fn arm_picture_caption(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Picture Caption List: a picture over each caption, in rows. Picture Accent Process: one + // row, arrows between the steps. + let process = d.kind == "pictureProcess"; + let cols = if process { n } else { ((nf * area.w / area.h.max(1.0) / 1.2).sqrt().ceil() as usize).clamp(1, n) }; + let rows = n.div_ceil(cols); + let gap = if process { area.w * 0.05 } else { 0.0 }; + let w = (area.w - gap * (cols as f64 - 1.0)) / cols as f64; + let h = area.h / rows as f64; + let bw = if process { w } else { w * 0.9 }; + let ch = (h * 0.9).min(bw * 1.1); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let in_row = if r + 1 == rows { n - r * cols } else { cols }; + let x0 = area.x + (area.w - in_row as f64 * w - gap * (in_row as f64 - 1.0)) / 2.0; + let x = x0 + c as f64 * (w + gap) + (w - bw) / 2.0; + let y = area.y + r as f64 * h + (h - ch) / 2.0; + let ph = ch * 0.62; + out.push(( + picture_shape(d, if process { "ellipse" } else { "rect" }, Xfrm::new(x + (bw - ph.min(bw)) / 2.0, y, ph.min(bw), ph), i, g[0]), + vec![], + )); + l.titled(out, if process { "roundRect" } else { "rect" }, Xfrm::new(x, y + ph + ch * 0.03, bw, ch * 0.35), i, g); + if process && i + 1 < n { + let a = (gap * 0.7).min(ch * 0.2); + l.arrow(out, Xfrm::new(x + bw + (gap - a) / 2.0, y + ph / 2.0 - a / 2.0, a, a), 0.0, i); + } + } +} + +fn arm_text_cycle(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Text Cycle: the text alone around a circle, arrows between. Continuous Cycle: boxes on + // a ring. + let text_only = d.kind == "textCycle"; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * 0.8).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + if !text_only { + let ring = r * 2.0 + dd * 0.3; + let mut sh = deco("donut", Xfrm::new(cx - ring / 2.0, cy - ring / 2.0, ring, ring), 0.0); + sh.geom = Geom::Preset { name: "donut".into(), adj: vec![7000.0] }; + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = (dd * 1.35, dd * 0.7); + let mut sh = text_shape("roundRect", Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), &l.nodes(g), true); + paint(d, &mut sh, i, false); + if text_only { + sh.fill = Some(Fill::None); + sh.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + sh.effects = None; + let c = ColorRef::scheme(accent(d, i)).with(ColorTransform::LumMod(75_000)); + if let Some(b) = &mut sh.text { + for p in &mut b.paragraphs { + for r in &mut p.runs { + r.props.fill = Some(Fill::solid(c.clone())); + } + } + } + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.3).max(6.0); + l.arrow(out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), (i as f64 + 0.5) / nf * 360.0, i); + } + } + out.push((sh, g.clone())); + } +} + +fn arm_segmented_cycle(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges. + // The text sits in a clear box over each segment's middle. + let ring = d.kind == "segmentedCycle"; + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let gap = if ring { 2.0 } else { 0.0 }; + for (i, g) in gs.iter().enumerate() { + // Angles clockwise from 3 o'clock, starting at the top. + let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0; + let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0; + let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0); + let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round(); + // blockArc runs from its first angle to its second clockwise; so does pie. + sh.geom = Geom::Preset { + name: if ring { "blockArc" } else { "pie" }.into(), + adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] }, + }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let m = ((a0 + a1) / 2.0).to_radians(); + let rr = if ring { s * 0.4375 } else { s * 0.3 }; + let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9); + let th = s * 0.16; + let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &l.nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } +} + +fn arm_multidirectional_cycle(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Multidirectional Cycle: nodes around a circle, double-headed arrows between them. + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + l.titled(out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g); + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.5).max(8.0); + let mut sh = + deco("leftRightArrow", Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4), (i as f64 + 0.5) / nf * 360.0); + paint(d, &mut sh, i, true); + out.push((sh, vec![])); + } + } +} + +fn arm_cycle_matrix(l: &L, out: &mut Vec) { + let L { d, area, gs, .. } = *l; + // Cycle Matrix: four quarters of a circle in the middle with the top-level text, each + // quarter's sub-points in a box at its corner. + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let c = s * 0.56; + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let b = s * 0.4; + let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b); + if g.len() > 1 { + let mut sh = text_shape("roundRect", bx, &l.nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + if let Some(body) = &mut sh.text { + // Text in the box's outer part, clear of the circle. + body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 }); + body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 }); + body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } + out.push((sh, g[1..].to_vec())); + } + } + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5; + let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0); + sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let q = c * 0.3; + let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &l.nodes(&g[..1]), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g[..1].to_vec())); + } +} + +fn arm_segmented_pyramid(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, โ€ฆ), one per node, + // every other one upside down. + let rows = (nf.sqrt().ceil() as usize).max(1); + let s = area.h.min(area.w / 1.15); + let pw = s * 1.15; + let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0); + let (tw, th) = (pw / rows as f64, s / rows as f64); + let mut k = 0; + 'rows: for r in 0..rows { + let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0; + for c in 0..(2 * r + 1) { + let Some(g) = gs.get(k) else { break 'rows }; + let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th); + let mut sh = text_shape("rect", x, &l.nodes(g), true); + // Drawn as a polygon (not a flipped preset) so upside-down text stays upright. + let path = if c % 2 == 1 { + format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0) + } else { + format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0) + }; + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { w: tw, h: th, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], + }; + if let Some(b) = &mut sh.text { + // The text in the triangle's wide half. + b.body.inset_l = Some(tw * 0.22); + b.body.inset_r = Some(tw * 0.22); + if c % 2 == 1 { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + b.body.inset_b = Some(th * 0.35); + } else { + b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom); + b.body.inset_t = Some(th * 0.35); + } + } + paint(d, &mut sh, k, false); + out.push((sh, g.clone())); + k += 1; + } + } +} + +fn arm_vertical_box_list(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Vertical Box List: a pale box per node with its sub-points, its title on a tab + // overlapping the box's top left. + let h = area.h / nf; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * h; + let tab_h = (h * 0.3).min(40.0); + let mut bx = text_shape("rect", Xfrm::new(area.x, y + tab_h / 2.0, area.w, h * 0.9 - tab_h / 2.0), &l.nodes(&g[1..]), false); + paint(d, &mut bx, i, true); + if let Some(b) = &mut bx.text { + b.body.inset_t = Some(tab_h * 0.6); + } + out.push((bx, g[1..].to_vec())); + l.titled(out, "roundRect", Xfrm::new(area.x + area.w * 0.05, y, area.w * 0.6, tab_h), i, &g[..1]); + } +} + +fn arm_trapezoid_list(l: &L, out: &mut Vec) { + let L { d, area, gs, .. } = *l; + // Trapezoid List: a column per node, each a trapezoid with its sub-points. Table List: the + // first node as a header bar over a column per other node. + let table = d.kind == "tableList"; + let (head, cols): (Option<&Vec>, &[Vec]) = + if table { gs.split_first().map(|(h, r)| (Some(h), r)).unwrap_or((None, &[])) } else { (None, gs) }; + let top = if head.is_some() { area.h * 0.28 } else { 0.0 }; + if let Some(h) = head { + l.titled(out, "rect", Xfrm::new(area.x, area.y, area.w, top * 0.9), 0, h); + } + let m = cols.len().max(1) as f64; + let w = area.w / m; + for (k, g) in cols.iter().enumerate() { + let i = k + usize::from(table); + let x = Xfrm::new(area.x + k as f64 * w + w * 0.02, area.y + top, w * 0.96, area.h - top); + let mut sh = text_shape(if table { "rect" } else { "trapezoid" }, x, &l.nodes(g), true); + if let Some(b) = &mut sh.text { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + if !table { + b.body.inset_l = Some(x.w * 0.2); + b.body.inset_r = Some(x.w * 0.2); + b.body.inset_t = Some(x.h * 0.08); + } + } + paint(d, &mut sh, i, table); + out.push((sh, g.clone())); + } +} + +fn arm_accent_process(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Accent Process: a title box per step with its sub-points in a pale box below and to the + // right, arrows between the titles. + let gap = area.w * 0.05; + let w = (area.w - gap * (nf - 1.0)) / nf; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * (w + gap); + let th = area.h * 0.22; + if g.len() > 1 { + let mut sh = text_shape("rect", Xfrm::new(x + w * 0.12, area.y + th * 0.7, w * 0.88, area.h - th * 0.7), &l.nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + if let Some(b) = &mut sh.text { + b.body.inset_t = Some(th * 0.45); + } + out.push((sh, g[1..].to_vec())); + } + l.titled(out, "roundRect", Xfrm::new(x, area.y, w * 0.85, th), i, &g[..1]); + if i + 1 < n { + let a = gap * 0.8; + l.arrow(out, Xfrm::new(x + w + (gap - a) / 2.0 - w * 0.07, area.y + th / 2.0 - a / 2.0, a, a), 0.0, i); + } + } +} + +fn arm_continuous_block_process(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Continuous Block Process: one big pale arrow behind a row of boxes. + let mut big = deco("rightArrow", Xfrm::new(area.x, area.y + area.h * 0.15, area.w, area.h * 0.7), 0.0); + paint(d, &mut big, 0, true); + out.push((big, vec![])); + let w = area.w * 0.85 / nf; + let h = area.h * 0.32; + for (i, g) in gs.iter().enumerate() { + let x = area.x + area.w * 0.02 + i as f64 * w; + l.titled(out, "roundRect", Xfrm::new(x + w * 0.05, area.y + (area.h - h) / 2.0, w * 0.9, h), i, g); + } +} + +fn arm_bending_process(l: &L, out: &mut Vec) { + let L { area, gs, n, nf, .. } = *l; + // Basic Bending Process: rows of boxes read left to right, wrapping, arrows between. + let cols = ((nf * area.w / area.h.max(1.0) / 1.5).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols); + let (cw, rh) = (area.w / cols as f64, area.h / rows as f64); + let (bw, bh) = (cw * 0.72, (rh * 0.75).min(cw * 0.72 * 0.6)); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let x = area.x + c as f64 * cw; + let y = area.y + r as f64 * rh + (rh - bh) / 2.0; + l.titled(out, "rect", Xfrm::new(x, y, bw, bh), i, g); + if i + 1 < n { + let a = (cw - bw) * 0.7; + if c + 1 < cols { + l.arrow(out, Xfrm::new(x + bw + (cw - bw - a) / 2.0, y + bh / 2.0 - a / 2.0, a, a), 0.0, i); + } else { + // The bend: down from the row's last box towards the next row's first. + let a = (rh - bh).min(a * 1.5).max(6.0); + l.arrow(out, Xfrm::new(x + bw / 2.0 - a / 2.0, y + bh + (rh - bh - a) / 2.0, a, a), 90.0, i); + } + } + } +} + +fn arm_linear_venn(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Linear Venn: overlapping circles in a row. Stacked Venn: circles nested from the bottom, + // the text near each one's top. + let linear = d.kind == "linearVenn"; + if linear { + let dc = (area.w / (nf * 0.75 + 0.25)).min(area.h); + let step = dc * 0.75; + let x0 = area.x + (area.w - (step * (nf - 1.0) + dc)) / 2.0; + for (i, g) in gs.iter().enumerate() { + let mut sh = text_shape("ellipse", Xfrm::new(x0 + i as f64 * step, area.y + (area.h - dc) / 2.0, dc, dc), &l.nodes(g), true); + paint(d, &mut sh, i, false); + if let Some(Fill::Solid { color: c }) = &mut sh.fill { + *c = c.clone().with(ColorTransform::Alpha(55_000)); + } + out.push((sh, g.clone())); + } + } else { + let s = area.h.min(area.w); + let (cx, bottom) = (area.x + area.w / 2.0, area.y + (area.h + s) / 2.0); + for (i, _) in gs.iter().enumerate() { + let dc = s * (1.0 - i as f64 / nf); + let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, bottom - dc, dc, dc), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + // Each circle's text in the band between its top and the next circle's. + let band = s / nf; + for (i, g) in gs.iter().enumerate() { + let dc = s * (1.0 - i as f64 / nf); + let top = bottom - dc; + let h = if i + 1 < n { band } else { dc }; + let w = dc * if i + 1 < n { 0.6 } else { 0.7 }; + let mut t = text_shape("rect", Xfrm::new(cx - w / 2.0, top + if i + 1 < n { band * 0.05 } else { 0.0 }, w, h * 0.9), &l.nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } +} + +fn arm_nested_target(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Nested Target: rounded boxes nested inside each other, the text on each one's top band. + let band_x = area.w / 2.0 / nf * 0.6; + let band_y = area.h / nf * 0.9 / 2.0; + for (i, _) in gs.iter().enumerate() { + let (dx, dy) = (band_x * i as f64, band_y * i as f64); + let mut sh = deco("roundRect", Xfrm::new(area.x + dx, area.y + dy, area.w - 2.0 * dx, area.h - 2.0 * dy), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + for (i, g) in gs.iter().enumerate() { + let (dx, dy) = (band_x * i as f64, band_y * i as f64); + let h = if i + 1 < n { band_y } else { area.h - 2.0 * dy }; + let mut t = + text_shape("rect", Xfrm::new(area.x + dx + band_x * 0.3, area.y + dy, (area.w - 2.0 * dx - band_x * 0.6).max(1.0), h), &l.nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } +} + +fn arm_grid_matrix(l: &L, out: &mut Vec) { + let L { d, area, gs, .. } = *l; + // Grid Matrix: four quadrants on crossed axes. + let s = area.h.min(area.w); + let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); + let t = s * 0.06; + for (preset, x) in [("leftRightArrow", Xfrm::new(x0, y0 + (s - t) / 2.0, s, t)), ("upDownArrow", Xfrm::new(x0 + (s - t) / 2.0, y0, t, s))] { + let mut sh = deco(preset, x, 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + let q = s / 2.0 - t * 1.2; + for (i, g) in gs.iter().take(4).enumerate() { + let (c, r) = ((i % 2) as f64, (i / 2) as f64); + let x = Xfrm::new(x0 + c * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - c), y0 + r * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - r), q, q); + l.titled(out, "rect", x, i, g); + } +} + +fn arm_gear(l: &L, out: &mut Vec) { + let L { area, gs, .. } = *l; + // Gear: up to three interlocking gears, the first the biggest. + let s = area.h.min(area.w * 0.62); + let (x0, y0) = (area.x + (area.w - s / 0.62) / 2.0, area.y + (area.h - s) / 2.0); + let spots = [(0.0, 0.18, 0.66, "gear9"), (0.5, 0.0, 0.48, "gear6"), (0.56, 0.48, 0.42, "gear6")]; + for (i, g) in gs.iter().take(3).enumerate() { + let (fx, fy, fs, preset) = spots[i]; + let z = s * fs; + l.titled(out, preset, Xfrm::new(x0 + fx * s * 1.3, y0 + fy * s, z, z), i, g); + } +} + +fn arm_funnel(l: &L, out: &mut Vec) { + let L { d, area, gs, .. } = *l; + // Funnel: the ideas poured in at the top (up to three), the last one coming out below. + let s = area.h.min(area.w); + let (cx, y0) = (area.x + area.w / 2.0, area.y); + let fw = s * 0.8; + let fh = s * 0.62; + let mut f = deco("funnel", Xfrm::new(cx - fw / 2.0, y0, fw, fh), 0.0); + paint(d, &mut f, 0, true); + out.push((f, vec![])); + let (inside, last) = match gs.split_last() { + Some((l, r)) if !r.is_empty() => (r, Some(l)), + _ => (gs, None), + }; + let k = inside.len().min(3); + let dc = fw * 0.27; + let spots = [(0.0, 0.04), (-0.2, 0.3), (0.2, 0.3)]; + for (i, g) in inside.iter().take(3).enumerate() { + let (dx, dy) = if k == 1 { (0.0, 0.15) } else { spots[i] }; + l.titled(out, "ellipse", Xfrm::new(cx + dx * fw - dc / 2.0, y0 + dy * fh + fh * 0.05, dc, dc), i, g); + } + if let Some(g) = last { + let a = s * 0.12; + let mut ar = deco("downArrow", Xfrm::new(cx - a / 2.0, y0 + fh, a, a * 1.2), 0.0); + paint(d, &mut ar, k, true); + out.push((ar, vec![])); + l.titled(out, "roundRect", Xfrm::new(cx - fw * 0.35, y0 + fh + a * 1.3, fw * 0.7, (s - fh - a * 1.3).max(10.0)), k, g); + } +} + +fn arm_picture_h_list(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Horizontal Picture List: a column per node, a big picture over its text. + let w = area.w / nf; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * w + w * 0.04; + let bw = w * 0.92; + let ph = area.h * 0.55; + out.push((picture_shape(d, "rect", Xfrm::new(x, area.y, bw, ph), i, g[0]), vec![])); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + ph, bw, area.h - ph), &l.nodes(g), true); + if let Some(b) = &mut sh.text { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + } + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); + } +} + +fn arm_timeline(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Basic Timeline: an arrow across the middle, a dot per node on it, the text above and + // below in turn. + let mid = area.y + area.h / 2.0; + let bar = (area.h * 0.08).max(6.0); + let mut sh = deco("rightArrow", Xfrm::new(area.x, mid - bar, area.w, bar * 2.0), 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + let w = area.w * 0.92 / nf; + for (i, g) in gs.iter().enumerate() { + let cx = area.x + w * (i as f64 + 0.5); + let dot = bar * 1.4; + let mut dsh = deco("ellipse", Xfrm::new(cx - dot / 2.0, mid - dot / 2.0, dot, dot), 0.0); + paint(d, &mut dsh, i, false); + out.push((dsh, vec![])); + let th = area.h / 2.0 - bar * 1.6; + let y = if i % 2 == 0 { area.y } else { mid + bar * 1.6 }; + let mut t = text_shape("rect", Xfrm::new(cx - w * 0.48, y, w * 0.96, th.max(1.0)), &l.nodes(g), true); + if let Some(b) = &mut t.text { + b.body.anchor = Some(if i % 2 == 0 { deckcraft_model::text::Anchor::Bottom } else { deckcraft_model::text::Anchor::Top }); + } + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + out.push((t, g.clone())); + } +} + +fn arm_venn(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Basic Venn: see-through circles overlapping in a ring (two or three in a row/triangle). + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = if n == 1 { s } else { s * 0.62 }; + let r = if n == 1 { 0.0 } else { (s - dc) / 2.0 }; + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 + if n == 2 { 180.0 } else { -90.0 }).to_radians(); + let mut sh = text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dc / 2.0, cy + r * a.sin() - dc / 2.0, dc, dc), &l.nodes(g), true); + paint(d, &mut sh, i, false); + if let Some(Fill::Solid { color: c }) = &mut sh.fill { + *c = c.clone().with(ColorTransform::Alpha(50_000)); + } + if let Some(b) = &mut sh.text { + // Text towards the circle's outer side, clear of the overlap. + let p = dc * 0.36; + let (dx, dy) = (a.cos() * p, a.sin() * p); + b.body.inset_l = Some((7.2 + dx).max(2.0)); + b.body.inset_r = Some((7.2 - dx).max(2.0)); + b.body.inset_t = Some((3.6 + dy).max(2.0)); + b.body.inset_b = Some((3.6 - dy).max(2.0)); + } + out.push((sh, g.clone())); + } +} + +fn arm_target(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Basic Target: nested rings, the outermost first, each ring's text in a box beside it + // with a line from the ring. + let s = area.h.min(area.w * 0.5); + let (cx, cy) = (area.x + s / 2.0, area.y + area.h / 2.0); + let band = s / 2.0 / nf; + for (i, _) in gs.iter().enumerate() { + let dc = s - 2.0 * band * i as f64; + let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + let tx = area.x + s + area.w * 0.06; + let rh = area.h / nf; + for (i, g) in gs.iter().enumerate() { + let ty = area.y + rh * (i as f64 + 0.5); + // The point on ring i's band level with the text, else its top. + let ry = cy - s / 2.0 + band * (i as f64 + 0.5); + out.push((line_shape((cx, ry.min(ty)), (tx, ty)), vec![])); + let mut t = text_shape("rect", Xfrm::new(tx, ty - rh * 0.45, (area.x + area.w - tx).max(1.0), rh * 0.9), &l.nodes(g), true); + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + if let Some(b) = &mut t.text { + for p in &mut b.paragraphs { + p.props.align = Some(Align::Left); + } + } + out.push((t, g.clone())); + } +} diff --git a/crates/engine/src/cmd/smartart/nodes.rs b/crates/engine/src/cmd/smartart/nodes.rs index 5c3fd7ac..b87edd79 100644 --- a/crates/engine/src/cmd/smartart/nodes.rs +++ b/crates/engine/src/cmd/smartart/nodes.rs @@ -1,5 +1,5 @@ use super::paint::layout_items; -use super::*; +use super::{Diagram, DiagramNode, MAX_LEVEL, MAX_SHAPES, Shape, Xfrm}; /// Nodes from text lines: each leading tab is one outline level. pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec { diff --git a/crates/engine/src/cmd/smartart/paint.rs b/crates/engine/src/cmd/smartart/paint.rs index 39286063..fc75a1f9 100644 --- a/crates/engine/src/cmd/smartart/paint.rs +++ b/crates/engine/src/cmd/smartart/paint.rs @@ -1,6 +1,9 @@ use super::layout::layout_raw; use super::nodes::Item; -use super::*; +use super::{ + Align, AutoFit, ColorRef, ColorTransform, Diagram, DiagramNode, Effects, Fill, Geom, Gradient, GradientShape, GradientStop, Line, MAX_LEVEL, + Paragraph, SchemeSlot, Shadow, Shape, ShapeStyle, TextBody, Xfrm, +}; fn slot(i: usize) -> SchemeSlot { [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6] diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 6688f17a..6f85df87 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -24,8 +24,6 @@ mod layout; use autocorrect::autocorrect_options; pub use autocorrect::{AUTOCORRECT, AutoList, correct_word}; -#[cfg(test)] -use caret::count_param; pub(crate) use caret::{apply_leading_tabs, delete_dir}; use caret::{delete, delete_surrounding, get, insert, move_caret, select, select_all, select_para, select_word, set}; use editing::{edit, exit}; diff --git a/crates/engine/src/cmd/text/autocorrect.rs b/crates/engine/src/cmd/text/autocorrect.rs index 33a4e8d6..d1cb9dcd 100644 --- a/crates/engine/src/cmd/text/autocorrect.rs +++ b/crates/engine/src/cmd/text/autocorrect.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Result, Session, TextSel, Value, bad, body_mut, body_of, ed, edit_text, json, str_param}; /// Built-in AutoCorrect replacements (common slips and symbols). pub const AUTOCORRECT: &[(&str, &str)] = &[ diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs index 48293bda..c7305d62 100644 --- a/crates/engine/src/cmd/text/caret.rs +++ b/crates/engine/src/cmd/text/caret.rs @@ -2,7 +2,10 @@ use super::autocorrect::undo_auto_list; use super::editing::{inherited_aligns, insert_text, promote_rtl_if_needed}; use super::ime::{composition_range, model_pos, walk_backward, walk_forward}; use super::layout::{ctx_for, fit_text_box, layout_for, pos_param}; -use super::*; +use super::{ + DocState, HistoryEntry, Pos, Result, Session, ShapeId, ShapeKind, TextBody, Value, bad, body_of, bool_or, ed, edit_shapes, edit_text, id_param, + json, ok, push_undo, str_param, xfrm_of, +}; /// A table cell's text laid out the way it is drawn, mapped into slide coordinates. pub(super) fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option { diff --git a/crates/engine/src/cmd/text/editing.rs b/crates/engine/src/cmd/text/editing.rs index 262b4cdd..7e2c37cb 100644 --- a/crates/engine/src/cmd/text/editing.rs +++ b/crates/engine/src/cmd/text/editing.rs @@ -1,6 +1,9 @@ use super::autocorrect::autocorrect; use super::layout::{ctx_for, pos_param}; -use super::*; +use super::{ + Align, Pos, Result, Run, RunKind, RunProps, Session, ShapeId, ShapeKind, TextBody, TextSel, Value, bad, body_of, bool_or, ed, edit_text, + id_param, json, ok, resolve, shape_of, +}; pub(super) fn edit(s: &mut Session, p: &Value) -> Result { if bool_or(p, "notes", false) { diff --git a/crates/engine/src/cmd/text/formatting.rs b/crates/engine/src/cmd/text/formatting.rs index 3287df94..158af096 100644 --- a/crates/engine/src/cmd/text/formatting.rs +++ b/crates/engine/src/cmd/text/formatting.rs @@ -1,5 +1,8 @@ use super::layout::fit_text_box; -use super::*; +use super::{ + DocState, Hyperlink, Pos, Result, RunProps, SELECT_TEXT_OR_SHAPE, Session, ShapeId, ShapeKind, TextBody, TextSel, Value, bad, body_of, ed, + edit_shapes, edit_text, json, ok, str_param, +}; /// Apply run formatting to the text selection (or, when not editing, to all text of the /// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour), diff --git a/crates/engine/src/cmd/text/ime.rs b/crates/engine/src/cmd/text/ime.rs index 9ec37195..27499546 100644 --- a/crates/engine/src/cmd/text/ime.rs +++ b/crates/engine/src/cmd/text/ime.rs @@ -1,7 +1,7 @@ use super::caret::cell_on_slide; use super::editing::unlinked_at_link_end; use super::layout::{ctx_for, layout_for}; -use super::*; +use super::{DocState, Paragraph, Result, Session, TextBody, TextSel, Value, bad, body_of, ed, edit_text, json, str_param, xfrm_of}; /// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query. /// @@ -598,9 +598,9 @@ mod ime_surrounding_tests { s.delete_surrounding(usize::MAX, 0, false).unwrap(); assert_eq!(text(&s, id), "รฉ๐Ÿ˜€b", "everything before the caret goes, no panic"); let expected = usize::try_from(4_294_967_296u64).unwrap_or(usize::MAX); - assert_eq!(super::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected); + assert_eq!(super::super::caret::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected); assert!(expected > 0, "the count is clamped, not wrapped to nothing"); - assert_eq!(super::count_param(&json!({}), "before"), 0); + assert_eq!(super::super::caret::count_param(&json!({}), "before"), 0); } /// A non-composing input-method deletion is exactly one undo step; the manual history entry diff --git a/crates/engine/src/cmd/text/layout.rs b/crates/engine/src/cmd/text/layout.rs index 715d5110..866eb736 100644 --- a/crates/engine/src/cmd/text/layout.rs +++ b/crates/engine/src/cmd/text/layout.rs @@ -1,5 +1,8 @@ use super::caret::cell_on_slide; -use super::*; +use super::{ + Affine, Arc, AutoFit, Ctx, DocState, Paragraph, Point, Pos, Presentation, Rect, Result, Selection, Session, Shape, ShapeId, Target, TextBody, + TextSel, Value, ed, resolve, xfrm_of, +}; /// Refit a shape after its text changed: remember the shrink-on-overflow scale the layout /// computes (#724), then grow/shrink an auto-sizing text box (Resize shape to fit text) to its diff --git a/crates/engine/src/tools/click.rs b/crates/engine/src/tools/click.rs index 4afff265..0f066dc0 100644 --- a/crates/engine/src/tools/click.rs +++ b/crates/engine/src/tools/click.rs @@ -1,5 +1,5 @@ use super::hit::{EditZone, edit_zone, edited_box, shape_in_front, text_member_at, text_pos}; -use super::*; +use super::{Drag, Hit, Point, Result, Session, ShapeId, ShapeKind, TextSel, ToolKind, Value, cmd, json, xfrm_of}; impl Session { pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result { diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index fcdaf3da..21aac4f3 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -1,5 +1,16 @@ use super::hit::{constrain_45, line_xfrm, resize_box}; -use super::*; +use super::{ + Affine, DRAW_TABLE, Drag, Mods, Point, Rect, Result, Session, ShapeId, ShapeKind, TextSel, ToolKind, Value, Vec2, Xfrm, cmd, json, set_warp_adj, +}; + +struct MoveDrag { + ids: Vec, + start: Point, + origs: Vec<(ShapeId, Xfrm)>, + moved: bool, + duplicate: bool, + enter_text: Option<(ShapeId, Point)>, +} impl Session { pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { @@ -13,55 +24,7 @@ impl Session { self.tool.guide_drag = Some((index, pos)); } Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { - let mut d = p - start; - if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { - return Ok(Value::Null); - } - if !moved { - self.begin(if duplicate { "Duplicate" } else { "Move" }); - if duplicate { - // Alt-drag: drag a copy, leave the original. - self.execute_dup_in_place(&ids)?; - } - } - let ids_now = if duplicate { self.doc()?.selection.shapes.clone() } else { ids.clone() }; - if mods.shift { - if d.x.abs() > d.y.abs() { - d.y = 0.0; - } else { - d.x = 0.0; - } - } - // Smart guides on the moved group's bounds. - let bounds = origs.iter().map(|(_, x)| x.bounds()).reduce(|a, b| a.union(b)).unwrap_or_default(); - let moved_b = bounds + d; - let (snap, guides) = self.snap_rect(moved_b, &ids_now, tol * 1.5, !mods.cmd); - d += snap; - self.tool.guides = guides; - let origs2: Vec<(ShapeId, Xfrm)> = - if duplicate { ids_now.iter().copied().zip(origs.iter().map(|o| o.1)).collect() } else { origs.clone() }; - self.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("move", "no slide"))?; - for (id, o) in &origs2 { - if let Some(sh) = deckcraft_model::find_shape_mut(list, *id) { - sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o }); - } - } - // A connector dragged away from shapes that stay put comes unglued at those ends. - let moved: Vec = origs2.iter().map(|o| o.0).collect(); - 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] { - if c.is_some_and(|(to, _)| !moved.contains(&to)) { - *c = None; - } - } - } - } - Ok(()) - })?; - self.tool.drag = - Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) }); + return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); } Drag::Resize { id, handle, orig, start } => { let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt); @@ -110,39 +73,7 @@ impl Session { self.set_xfrm(*id, nx)?; } } - Drag::Adjust { id, index, orig } => { - let st = self.doc()?; - let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; - let local = orig.affine().inverse() * p; - let geo = deckcraft_render::shape_geometry(&sh, orig.w, orig.h); - let Some(h) = geo.handles.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; - let free2d = matches!(sh.geom.preset_name(), Some(n) if n.contains("Callout") && !n.contains("Arrow")); - let vals: Vec<(usize, f64)> = if free2d { - let dx = (local.x - orig.w / 2.0) / orig.w.max(1e-6) * 1e5; - let dy = (local.y - orig.h / 2.0) / orig.h.max(1e-6) * 1e5; - vec![(0, dx.round()), (1, dy.round())] - } else { - vec![(index, h.value_at(local))] - }; - self.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; - if let Some(sh) = deckcraft_model::find_shape_mut(list, id) - && let deckcraft_model::Geom::Preset { name, adj } = &mut sh.geom - { - let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]); - for (i, v) in &vals { - while adj.len() <= *i { - let k = adj.len(); - adj.push(defaults.get(k).copied().unwrap_or(0.0)); - } - if let Some(slot) = adj.get_mut(*i) { - *slot = *v; - } - } - } - Ok(()) - })?; - } + Drag::Adjust { id, index, orig } => return self.drag_adjust(id, index, orig, p), Drag::Vertex { id, path, index } => { self.tool.drag = Some(Drag::Vertex { id, path, index }); self.edit_vertex(id, path, index, p)?; @@ -187,71 +118,8 @@ impl Session { Drag::Marquee { start, .. } => { self.tool.marquee = Some(Rect::from_points(start, p)); } - Drag::Create { start } => { - let line = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); - let x = if line { - let mut q = if mods.shift { constrain_45(start, p) } else { p }; - if let Some((_, _, at)) = self.update_sites(q, tol, None) { - q = at; - } - let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start); - line_xfrm(s0, q, 0.0) - } else if matches!(&self.tool.kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) - && crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() - { - // The pencil inside a table draws a straight border, across or down. - let q = if (p.x - start.x).abs() >= (p.y - start.y).abs() { Point::new(p.x, start.y) } else { Point::new(start.x, p.y) }; - let r = Rect::from_points(start, q); - Xfrm::new(r.x0, r.y0, r.width(), r.height()) - } else { - let mut r = Rect::from_points(start, p); - if mods.shift { - let s = r.width().max(r.height()); - let sx = if p.x < start.x { -s } else { s }; - let sy = if p.y < start.y { -s } else { s }; - r = Rect::from_points(start, start + Vec2::new(sx, sy)); - } - if mods.alt { - let d = p - start; - r = Rect::new(start.x - d.x.abs(), start.y - d.y.abs(), start.x + d.x.abs(), start.y + d.y.abs()); - } - Xfrm::new(r.x0, r.y0, r.width(), r.height()) - }; - self.tool.preview = Some(x); - } - Drag::TextSelect { shape, press, .. } => { - // In a table, leaving the cell the drag started in selects whole cells, as in - // PowerPoint; coming back to that cell selects text in it again. - let st = self.doc()?; - if let Some(start) = press.as_ref().and_then(|t| t.cell) - && let Some(f) = st.shape(shape).and_then(|sh| cmd::table::table_frame(st, sh)) - && let Some(cell) = f.cell_at(f.local(p)) - { - if cell != start { - self.select(|_, sel| { - sel.text = None; - sel.shapes = vec![shape]; - sel.cells = Some((shape, start, cell)); - })?; - return Ok(Value::Null); - } - if st.selection.text.is_none() { - self.select(|_, sel| { - sel.cells = None; - sel.text = press; - })?; - } - } - let st = self.doc()?; - if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { - let pos = cmd::text::text_pos_at(st, t, p); - self.select(|_, sel| { - if let Some(t) = sel.text.as_mut() { - t.caret = pos; - } - })?; - } - } + Drag::Create { start } => self.drag_create(start, p, mods, tol), + Drag::TextSelect { shape, press, .. } => return self.drag_text_select(shape, press, p), Drag::TableBorder { id, column, index, start, size, frame, moved } => { let local = frame.inverse() * p; let d = if column { local.x - start.x } else { local.y - start.y }; @@ -293,6 +161,162 @@ impl Session { Ok(Value::Null) } + fn drag_move(&mut self, drag: MoveDrag, p: Point, mods: Mods, tol: f64) -> Result { + let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag; + let mut d = p - start; + if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { + return Ok(Value::Null); + } + if !moved { + self.begin(if duplicate { "Duplicate" } else { "Move" }); + if duplicate { + // Alt-drag: drag a copy, leave the original. + self.execute_dup_in_place(&ids)?; + } + } + let ids_now = if duplicate { self.doc()?.selection.shapes.clone() } else { ids.clone() }; + if mods.shift { + if d.x.abs() > d.y.abs() { + d.y = 0.0; + } else { + d.x = 0.0; + } + } + // Smart guides on the moved group's bounds. + let bounds = origs.iter().map(|(_, x)| x.bounds()).reduce(|a, b| a.union(b)).unwrap_or_default(); + let moved_b = bounds + d; + let (snap, guides) = self.snap_rect(moved_b, &ids_now, tol * 1.5, !mods.cmd); + d += snap; + self.tool.guides = guides; + let origs2: Vec<(ShapeId, Xfrm)> = if duplicate { ids_now.iter().copied().zip(origs.iter().map(|o| o.1)).collect() } else { origs.clone() }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("move", "no slide"))?; + for (id, o) in &origs2 { + if let Some(sh) = deckcraft_model::find_shape_mut(list, *id) { + sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o }); + } + } + // A connector dragged away from shapes that stay put comes unglued at those ends. + let moved: Vec = origs2.iter().map(|o| o.0).collect(); + 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] { + if c.is_some_and(|(to, _)| !moved.contains(&to)) { + *c = None; + } + } + } + } + Ok(()) + })?; + self.tool.drag = + Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) }); + Ok(Value::Null) + } + + fn drag_adjust(&mut self, id: ShapeId, index: usize, orig: Xfrm, p: Point) -> Result { + let st = self.doc()?; + let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; + let local = orig.affine().inverse() * p; + let geo = deckcraft_render::shape_geometry(&sh, orig.w, orig.h); + let Some(h) = geo.handles.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; + let free2d = matches!(sh.geom.preset_name(), Some(n) if n.contains("Callout") && !n.contains("Arrow")); + let vals: Vec<(usize, f64)> = if free2d { + let dx = (local.x - orig.w / 2.0) / orig.w.max(1e-6) * 1e5; + let dy = (local.y - orig.h / 2.0) / orig.h.max(1e-6) * 1e5; + vec![(0, dx.round()), (1, dy.round())] + } else { + vec![(index, h.value_at(local))] + }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) + && let deckcraft_model::Geom::Preset { name, adj } = &mut sh.geom + { + let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]); + for (i, v) in &vals { + while adj.len() <= *i { + let k = adj.len(); + adj.push(defaults.get(k).copied().unwrap_or(0.0)); + } + if let Some(slot) = adj.get_mut(*i) { + *slot = *v; + } + } + } + Ok(()) + })?; + Ok(Value::Null) + } + + fn drag_create(&mut self, start: Point, p: Point, mods: Mods, tol: f64) { + let line = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); + let x = if line { + let mut q = if mods.shift { constrain_45(start, p) } else { p }; + if let Some((_, _, at)) = self.update_sites(q, tol, None) { + q = at; + } + let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start); + line_xfrm(s0, q, 0.0) + } else if matches!(&self.tool.kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) + && crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() + { + // The pencil inside a table draws a straight border, across or down. + let q = if (p.x - start.x).abs() >= (p.y - start.y).abs() { Point::new(p.x, start.y) } else { Point::new(start.x, p.y) }; + let r = Rect::from_points(start, q); + Xfrm::new(r.x0, r.y0, r.width(), r.height()) + } else { + let mut r = Rect::from_points(start, p); + if mods.shift { + let s = r.width().max(r.height()); + let sx = if p.x < start.x { -s } else { s }; + let sy = if p.y < start.y { -s } else { s }; + r = Rect::from_points(start, start + Vec2::new(sx, sy)); + } + if mods.alt { + let d = p - start; + r = Rect::new(start.x - d.x.abs(), start.y - d.y.abs(), start.x + d.x.abs(), start.y + d.y.abs()); + } + Xfrm::new(r.x0, r.y0, r.width(), r.height()) + }; + self.tool.preview = Some(x); + } + + fn drag_text_select(&mut self, shape: ShapeId, press: Option, p: Point) -> Result { + // In a table, leaving the cell the drag started in selects whole cells, as in + // PowerPoint; coming back to that cell selects text in it again. + let st = self.doc()?; + if let Some(start) = press.as_ref().and_then(|t| t.cell) + && let Some(f) = st.shape(shape).and_then(|sh| cmd::table::table_frame(st, sh)) + && let Some(cell) = f.cell_at(f.local(p)) + { + if cell != start { + self.select(|_, sel| { + sel.text = None; + sel.shapes = vec![shape]; + sel.cells = Some((shape, start, cell)); + })?; + return Ok(Value::Null); + } + if st.selection.text.is_none() { + self.select(|_, sel| { + sel.cells = None; + sel.text = press; + })?; + } + } + let st = self.doc()?; + if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { + let pos = cmd::text::text_pos_at(st, t, p); + self.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.caret = pos; + } + })?; + } + Ok(Value::Null) + } + /// Moves a vertex during a drag (one undo step for the whole drag). fn edit_vertex(&mut self, id: ShapeId, path: usize, index: usize, p: Point) -> Result<()> { let r = cmd::points::move_point_raw(self, id, path, index, p); diff --git a/crates/engine/src/tools/geometry.rs b/crates/engine/src/tools/geometry.rs index 617b9791..536a4ead 100644 --- a/crates/engine/src/tools/geometry.rs +++ b/crates/engine/src/tools/geometry.rs @@ -1,5 +1,5 @@ use super::hit::table_rows; -use super::*; +use super::{Drag, Guide, HistoryEntry, Point, Rect, Result, Session, Shape, ShapeId, ShapeKind, ToolKind, Value, Vec2, Xfrm, cmd, json, xfrm_of}; impl Session { /// End the freeform being drawn: create the shape (if it has at least two points). diff --git a/crates/engine/src/tools/hit.rs b/crates/engine/src/tools/hit.rs index e6105174..6a0da529 100644 --- a/crates/engine/src/tools/hit.rs +++ b/crates/engine/src/tools/hit.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, Hit, Point, Rect, Shape, ShapeId, ShapeKind, TextSel, Vec2, Xfrm, cmd, xfrm_of}; pub(super) fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool { st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box) diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index adba0a5f..bee8f320 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -1,5 +1,8 @@ use super::hit::{EditZone, contains_rect, edit_zone, edited_box, hit_shape, in_table_cell, is_text_first, shape_in_front}; -use super::*; +use super::{ + Arc, DRAW_TABLE, Drag, HistoryEntry, Hit, Mods, Point, PointerEvent, PointerKind, Rect, Result, Session, ShapeId, ShapeKind, TABLE_ERASER, + TextTarget, ToolKind, Value, Xfrm, box_handles, cmd, is_freeform_tool, json, wordart_handles, xfrm_of, +}; impl Session { pub fn set_tool(&mut self, kind: ToolKind) { diff --git a/crates/geom/src/preset/build.rs b/crates/geom/src/preset/build.rs index 1dc0b8e3..95b833ef 100644 --- a/crates/geom/src/preset/build.rs +++ b/crates/geom/src/preset/build.rs @@ -4,7 +4,24 @@ use super::builder::{ }; use super::catalog::{FillMode, Geometry, Handle, info}; use super::shapes::{action_button, cloud, math, ribbon, scroll, wedge_rect}; -use super::*; +use super::{Arc, BezPath, Point, Rect, Vec2, extra}; + +/// Size and adjust values shared by every preset-shape builder. +struct Ctx<'a> { + name: &'a str, + w: f64, + h: f64, + adj: &'a [f64], + defaults: &'a [f64], +} + +impl Ctx<'_> { + /// Adjust value `i`: the caller's value, else the preset default, else 0 (non-finite values become 0). + fn a(&self, i: usize) -> f64 { + let v = self.adj.get(i).copied().or_else(|| self.defaults.get(i).copied()).unwrap_or(0.0); + if v.is_finite() { v } else { 0.0 } + } +} /// Builds the geometry for preset `name` at size `w`ร—`h` with adjust values `adj` (missing /// values use the preset's defaults). Unknown names return `None`; the caller falls back to `rect`. @@ -12,17 +29,25 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { if !(w.is_finite() && h.is_finite()) { return None; } - let w = w.max(0.0); - let h = h.max(0.0); let defaults = info(name).map(|i| i.defaults).unwrap_or(&[]); - let a = |i: usize| -> f64 { - let v = adj.get(i).copied().or_else(|| defaults.get(i).copied()).unwrap_or(0.0); - if v.is_finite() { v } else { 0.0 } - }; - let ss = w.min(h); - let full = Rect::new(0.0, 0.0, w, h); + let c = Ctx { name, w: w.max(0.0), h: h.max(0.0), adj, defaults }; + rect_and_triangle_shapes(&c) + .or_else(|| polygon_and_symbol_shapes(&c)) + .or_else(|| bracket_and_arrow_shapes(&c)) + .or_else(|| flowchart_and_star_shapes(&c)) + .or_else(|| callout_wave_and_connector_shapes(&c)) + .or_else(|| extra::build(name, c.w, c.h, &|i| c.a(i))) +} + +fn rect_and_triangle_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); let mut b = Pb::new(); - let g = match name { + let g = match c.name { "rect" | "textBox" | "flowChartProcess" => simple(corner_rect(w, h, [Corner::Square; 4]), full, w, h), "actionButtonBlank" | "actionButtonBackPrevious" @@ -158,6 +183,20 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { sites: mid_sites(w, h), } } + _ => return None, + }; + Some(g) +} + +fn polygon_and_symbol_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { "pentagon" => simple(b.poly(&polygon_regular(5, w, h, -90.0)).take(), Rect::new(w * 0.2, h * 0.27, w * 0.8, h * 0.9), w, h), "heptagon" => simple(b.poly(&polygon_regular(7, w, h, -90.0)).take(), Rect::new(w * 0.16, h * 0.2, w * 0.84, h * 0.86), w, h), "decagon" => simple(b.poly(&polygon_regular(10, w, h, 0.0)).take(), Rect::new(w * 0.1, h * 0.17, w * 0.9, h * 0.83), w, h), @@ -548,6 +587,20 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { let dy4 = (ss * ss - g13 * g13).max(0.0).sqrt() * (h / 2.0) / ssf; simple(b.take(), Rect::new(g12 * w / ssf, h / 2.0 - dy4, g0w, h / 2.0 + dy4), w, h) } + _ => return None, + }; + Some(g) +} + +fn bracket_and_arrow_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { "cloud" | "cloudCallout" => cloud(name, w, h, a(0), a(1)), "bracketPair" => { let r = ss * a(0).clamp(0.0, 50000.0) / 1e5; @@ -826,6 +879,20 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { simple(b.poly(&pts).take(), Rect::from_points(Point::new(tr[0].0, tr[0].1), Point::new(tr[1].0, tr[1].1)).abs(), w, h) } "mathPlus" | "mathMinus" | "mathEqual" | "mathDivide" | "mathMultiply" | "mathNotEqual" => math(name, w, h, a(0), a(1), a(2)), + _ => return None, + }; + Some(g) +} + +fn flowchart_and_star_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { "flowChartPredefinedProcess" => { let r = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]).take(); let mut l = Pb::new(); @@ -1034,6 +1101,20 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { ); simple(b.poly(&pts).take(), Rect::new(w * 5372.0 / 21600.0, h * 6382.0 / 21600.0, w * 14640.0 / 21600.0, h * 15935.0 / 21600.0), w, h) } + _ => return None, + }; + Some(g) +} + +fn callout_wave_and_connector_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { "ribbon" | "ribbon2" => ribbon(name == "ribbon2", w, h, a(0), a(1)), "verticalScroll" | "horizontalScroll" => scroll(name == "verticalScroll", w, h, a(0)), "wave" | "doubleWave" => { @@ -1117,7 +1198,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { let l = Pb::new().m(0.0, 0.0).c(x / 2.0, 0.0, x, h / 4.0, x, h / 2.0).c(x, h * 0.75, (x + w) / 2.0, h, w, h).take(); Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] } } - _ => return extra::build(name, w, h, &a), + _ => return None, }; Some(g) } diff --git a/crates/geom/src/preset/builder.rs b/crates/geom/src/preset/builder.rs index 4b106f62..701d1524 100644 --- a/crates/geom/src/preset/builder.rs +++ b/crates/geom/src/preset/builder.rs @@ -1,5 +1,5 @@ use super::catalog::{FillMode, Geometry, Handle, SubPath}; -use super::*; +use super::{Arc, BezPath, Point, Rect, Vec2}; // ---------- path builder ---------- diff --git a/crates/geom/src/preset/catalog.rs b/crates/geom/src/preset/catalog.rs index ed02ab18..ca090f94 100644 --- a/crates/geom/src/preset/catalog.rs +++ b/crates/geom/src/preset/catalog.rs @@ -1,5 +1,5 @@ use super::builder::vis; -use super::*; +use super::{BezPath, Deserialize, Point, Rect, Serialize, Vec2}; /// How a sub-path is filled. `Lighten`/`Darken` variants shade the shape's fill (3-D looking faces). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/geom/src/preset/shapes.rs b/crates/geom/src/preset/shapes.rs index 13623fe3..15647543 100644 --- a/crates/geom/src/preset/shapes.rs +++ b/crates/geom/src/preset/shapes.rs @@ -1,6 +1,6 @@ use super::builder::{Corner, Pb, corner_rect, fill, mid_sites, shade, simple, stroke_only, tip_handle}; use super::catalog::{FillMode, Geometry}; -use super::*; +use super::{Point, Rect}; pub(super) fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry { let tip = Point::new(w / 2.0 + w * a0 / 1e5, h / 2.0 + h * a1 / 1e5); diff --git a/crates/h264/src/deblock.rs b/crates/h264/src/deblock.rs index 0d815aff..3781cab9 100644 --- a/crates/h264/src/deblock.rs +++ b/crates/h264/src/deblock.rs @@ -302,71 +302,12 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { let top = usable(if my > 0 { Some(addr - mb_w) } else { None }); let alpha_off = sq.alpha_offset; let beta_off = sq.beta_offset; - let t8 = q.transform_8x8; - // bS[dir][edge][segment]; dir 0 = vertical edges (x), 1 = horizontal edges (y) - let mut bs = [[[0u8; 4]; 4]; 2]; - if q.kind.is_intra() { - for dir in 0..2 { - if (if dir == 0 { left } else { top }).is_some() { - bs[dir][0] = [4; 4]; - } - for e in 1..4 { - if !(t8 && e % 2 == 1) { - bs[dir][e] = [3; 4]; - } - } - } - } else { - let qids = ref_ids8(q, sq); - let q_uniform = (0..2).all(|l| { - let r = q.ref_idx[l]; - r[1] == r[0] && r[2] == r[0] && r[3] == r[0] && q.mv[l].iter().all(|m| *m == q.mv[l][0]) - }); - let qm: [Motion; 16] = std::array::from_fn(|r| block_motion(q, &qids, r)); - for dir in 0..2 { - let neighbor = if dir == 0 { left } else { top }; - if let Some(n) = neighbor { - let p = &pic.mbs[n]; - if p.kind.is_intra() { - bs[dir][0] = [4; 4]; - } else { - let pids = ref_ids8(p, &pic.slices[p.slice_num as usize]); - for k in 0..4 { - let (rq, rp) = if dir == 0 { (k * 4, k * 4 + 3) } else { (k, 12 + k) }; - bs[dir][0][k] = if (p.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 { - 2 - } else { - motion_bs(&block_motion(p, &pids, rp), &qm[rq]) - }; - } - } - } - if q_uniform && q.nz_mask == 0 { - // one motion for the whole MB and no coefficients: all internal edges have bS 0 - continue; - } - for e in 1..4 { - if t8 && e % 2 == 1 { - continue; - } - for k in 0..4 { - let (rq, rp) = if dir == 0 { (k * 4 + e, k * 4 + e - 1) } else { (e * 4 + k, e * 4 + k - 4) }; - bs[dir][e][k] = if (q.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 { 2 } else { motion_bs(&qm[rp], &qm[rq]) }; - } - } - } - } + let bs = boundary_strengths(pic, q, sq, [left, top]); let qp_of = |st: &MbState| if st.kind == MbKind::IPcm { 0 } else { st.qp as i32 }; let q_qp = qp_of(q); let q_qpc = q.qpc; let p_qp = [left.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc)), top.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc))]; - let params = |qpp: i32, qpq: i32, b: [u8; 4]| { - let qpav = (qpp + qpq + 1) >> 1; - let index_a = (qpav + alpha_off).clamp(0, 51) as usize; - let index_b = (qpav + beta_off).clamp(0, 51) as usize; - let tc = |b: u8| if (1..4).contains(&b) { TC0[index_a][b as usize - 1] as i32 } else { 0 }; - EdgeParams { bs: b, tc0: [tc(b[0]), tc(b[1]), tc(b[2]), tc(b[3])], alpha: ALPHA[index_a] as i32, beta: BETA[index_b] as i32 } - }; + let offsets = (alpha_off, beta_off); let width = pic.planes.width; let cwidth = pic.planes.cwidth; // luma @@ -376,7 +317,7 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { continue; } let qpp = if e == 0 { p_qp[dir].map(|p| p.0).unwrap_or(q_qp) } else { q_qp }; - let ep = params(qpp, q_qp, bs[dir][e]); + let ep = edge_params(offsets, qpp, q_qp, bs[dir][e]); if ep.alpha == 0 || ep.beta == 0 { // filterSamplesFlag needs |p0 - q0| < alpha and |p1 - p0| < beta (8.7.2.2): no // sample of the edge changes (low QPs) @@ -398,7 +339,7 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { continue; } let qpp = if e == 0 { p_qp[dir].map(|p| p.1[c]).unwrap_or(q_qpc[c]) } else { q_qpc[c] } as i32; - let ep = params(qpp, q_qpc[c] as i32, bs[dir][e]); + let ep = edge_params(offsets, qpp, q_qpc[c] as i32, bs[dir][e]); if ep.alpha == 0 || ep.beta == 0 { continue; } @@ -413,6 +354,78 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { } } +/// Filter parameters of one edge from the neighbouring QPs and the slice's alpha/beta offsets. +fn edge_params((alpha_off, beta_off): (i32, i32), qpp: i32, qpq: i32, b: [u8; 4]) -> EdgeParams { + let qpav = (qpp + qpq + 1) >> 1; + let index_a = (qpav + alpha_off).clamp(0, 51) as usize; + let index_b = (qpav + beta_off).clamp(0, 51) as usize; + let tc = |b: u8| if (1..4).contains(&b) { TC0[index_a][b as usize - 1] as i32 } else { 0 }; + EdgeParams { bs: b, tc0: [tc(b[0]), tc(b[1]), tc(b[2]), tc(b[3])], alpha: ALPHA[index_a] as i32, beta: BETA[index_b] as i32 } +} + +/// bS[dir][edge][segment]; dir 0 = vertical edges (x), 1 = horizontal edges (y). `neighbors` is +/// `[left, top]`. +fn boundary_strengths(pic: &PicState, q: &MbState, sq: &SliceInfo, neighbors: [Option; 2]) -> [[[u8; 4]; 4]; 2] { + if q.kind.is_intra() { intra_strengths(q.transform_8x8, neighbors) } else { inter_strengths(pic, q, sq, neighbors) } +} + +fn intra_strengths(t8: bool, neighbors: [Option; 2]) -> [[[u8; 4]; 4]; 2] { + let mut bs = [[[0u8; 4]; 4]; 2]; + for dir in 0..2 { + if neighbors[dir].is_some() { + bs[dir][0] = [4; 4]; + } + for e in 1..4 { + if !(t8 && e % 2 == 1) { + bs[dir][e] = [3; 4]; + } + } + } + bs +} + +fn inter_strengths(pic: &PicState, q: &MbState, sq: &SliceInfo, neighbors: [Option; 2]) -> [[[u8; 4]; 4]; 2] { + let mut bs = [[[0u8; 4]; 4]; 2]; + let t8 = q.transform_8x8; + let qids = ref_ids8(q, sq); + let q_uniform = (0..2).all(|l| { + let r = q.ref_idx[l]; + r[1] == r[0] && r[2] == r[0] && r[3] == r[0] && q.mv[l].iter().all(|m| *m == q.mv[l][0]) + }); + let qm: [Motion; 16] = std::array::from_fn(|r| block_motion(q, &qids, r)); + for dir in 0..2 { + if let Some(n) = neighbors[dir] { + bs[dir][0] = edge_strengths_to_neighbor(&pic.mbs[n], &pic.slices[pic.mbs[n].slice_num as usize], q, &qm, dir); + } + if q_uniform && q.nz_mask == 0 { + // one motion for the whole MB and no coefficients: all internal edges have bS 0 + continue; + } + for e in 1..4 { + if t8 && e % 2 == 1 { + continue; + } + for k in 0..4 { + let (rq, rp) = if dir == 0 { (k * 4 + e, k * 4 + e - 1) } else { (e * 4 + k, e * 4 + k - 4) }; + bs[dir][e][k] = if (q.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 { 2 } else { motion_bs(&qm[rp], &qm[rq]) }; + } + } + } + bs +} + +/// bS of the macroblock edge shared with neighbour `p` (left for `dir` 0, top for `dir` 1). +fn edge_strengths_to_neighbor(p: &MbState, p_slice: &SliceInfo, q: &MbState, qm: &[Motion; 16], dir: usize) -> [u8; 4] { + if p.kind.is_intra() { + return [4; 4]; + } + let pids = ref_ids8(p, p_slice); + std::array::from_fn(|k| { + let (rq, rp) = if dir == 0 { (k * 4, k * 4 + 3) } else { (k, 12 + k) }; + if (p.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 { 2 } else { motion_bs(&block_motion(p, &pids, rp), &qm[rq]) } + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/h264/src/intra.rs b/crates/h264/src/intra.rs index decf2965..c2cd8fba 100644 --- a/crates/h264/src/intra.rs +++ b/crates/h264/src/intra.rs @@ -61,126 +61,102 @@ fn load_edge(plane: &[u8], stride: usize, x: usize, y: usize, n: usize, a: Avail /// Directional / DC prediction shared by Intra_4x4 and Intra_8x8 (8.3.1.2.x, 8.3.2.2.x). fn pred_nxn(mode: u8, e: &Edge, n: i32, a: Avail, out: &mut [u8], stride: usize) { - let mut put = |x: i32, y: i32, v: i32| out[y as usize * stride + x as usize] = v as u8; + let mut fill = |value: &dyn Fn(i32, i32) -> i32| { + for y in 0..n { + for x in 0..n { + out[y as usize * stride + x as usize] = value(x, y) as u8; + } + } + }; match mode { - 0 => { - for y in 0..n { - for x in 0..n { - put(x, y, e.t(x)); - } - } - } - 1 => { - for y in 0..n { - for x in 0..n { - put(x, y, e.l(y)); - } - } - } + 0 => fill(&|x, _| e.t(x)), + 1 => fill(&|_, y| e.l(y)), 2 => { - let shift = if n == 4 { 2 } else { 3 }; - let st: i32 = (0..n).map(|x| e.t(x)).sum(); - let sl: i32 = (0..n).map(|y| e.l(y)).sum(); - let dc = match (a.top, a.left) { - (true, true) => (st + sl + n) >> (shift + 1), - (false, true) => (sl + (n >> 1)) >> shift, - (true, false) => (st + (n >> 1)) >> shift, - _ => 128, - }; - for y in 0..n { - for x in 0..n { - put(x, y, dc); - } - } - } - 3 => { - for y in 0..n { - for x in 0..n { - let v = if x == n - 1 && y == n - 1 { - (e.t(2 * n - 2) + 3 * e.t(2 * n - 1) + 2) >> 2 - } else { - (e.t(x + y) + 2 * e.t(x + y + 1) + e.t(x + y + 2) + 2) >> 2 - }; - put(x, y, v); - } - } - } - 4 => { - for y in 0..n { - for x in 0..n { - let v = if x > y { - (e.t(x - y - 2) + 2 * e.t(x - y - 1) + e.t(x - y) + 2) >> 2 - } else if x < y { - (e.l(y - x - 2) + 2 * e.l(y - x - 1) + e.l(y - x) + 2) >> 2 - } else { - (e.t(0) + 2 * e.tl + e.l(0) + 2) >> 2 - }; - put(x, y, v); - } - } - } - 5 => { - for y in 0..n { - for x in 0..n { - let z = 2 * x - y; - let v = if z >= 0 && z % 2 == 0 { - (e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 1) >> 1 - } else if z > 0 { - (e.t(x - (y >> 1) - 2) + 2 * e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 2) >> 2 - } else if z == -1 { - (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 - } else { - (e.l(y - 2 * x - 1) + 2 * e.l(y - 2 * x - 2) + e.l(y - 2 * x - 3) + 2) >> 2 - }; - put(x, y, v); - } - } - } - 6 => { - for y in 0..n { - for x in 0..n { - let z = 2 * y - x; - let v = if z >= 0 && z % 2 == 0 { - (e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 1) >> 1 - } else if z > 0 { - (e.l(y - (x >> 1) - 2) + 2 * e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 2) >> 2 - } else if z == -1 { - (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 - } else { - (e.t(x - 2 * y - 1) + 2 * e.t(x - 2 * y - 2) + e.t(x - 2 * y - 3) + 2) >> 2 - }; - put(x, y, v); - } - } - } - 7 => { - for y in 0..n { - for x in 0..n { - let i = x + (y >> 1); - let v = if y % 2 == 0 { (e.t(i) + e.t(i + 1) + 1) >> 1 } else { (e.t(i) + 2 * e.t(i + 1) + e.t(i + 2) + 2) >> 2 }; - put(x, y, v); - } - } - } - _ => { - let zmax = 2 * n - 3; - for y in 0..n { - for x in 0..n { - let z = x + 2 * y; - let i = y + (x >> 1); - let v = if z < zmax && z % 2 == 0 { - (e.l(i) + e.l(i + 1) + 1) >> 1 - } else if z < zmax { - (e.l(i) + 2 * e.l(i + 1) + e.l(i + 2) + 2) >> 2 - } else if z == zmax { - (e.l(n - 2) + 3 * e.l(n - 1) + 2) >> 2 - } else { - e.l(n - 1) - }; - put(x, y, v); - } - } + let dc = dc_value(e, n, a); + fill(&|_, _| dc); } + 3 => fill(&|x, y| diagonal_down_left(e, n, x, y)), + 4 => fill(&|x, y| diagonal_down_right(e, x, y)), + 5 => fill(&|x, y| vertical_right(e, x, y)), + 6 => fill(&|x, y| horizontal_down(e, x, y)), + 7 => fill(&|x, y| vertical_left(e, x, y)), + _ => fill(&|x, y| horizontal_up(e, n, x, y)), + } +} + +fn dc_value(e: &Edge, n: i32, a: Avail) -> i32 { + let shift = if n == 4 { 2 } else { 3 }; + let st: i32 = (0..n).map(|x| e.t(x)).sum(); + let sl: i32 = (0..n).map(|y| e.l(y)).sum(); + match (a.top, a.left) { + (true, true) => (st + sl + n) >> (shift + 1), + (false, true) => (sl + (n >> 1)) >> shift, + (true, false) => (st + (n >> 1)) >> shift, + _ => 128, + } +} + +fn diagonal_down_left(e: &Edge, n: i32, x: i32, y: i32) -> i32 { + if x == n - 1 && y == n - 1 { + (e.t(2 * n - 2) + 3 * e.t(2 * n - 1) + 2) >> 2 + } else { + (e.t(x + y) + 2 * e.t(x + y + 1) + e.t(x + y + 2) + 2) >> 2 + } +} + +fn diagonal_down_right(e: &Edge, x: i32, y: i32) -> i32 { + if x > y { + (e.t(x - y - 2) + 2 * e.t(x - y - 1) + e.t(x - y) + 2) >> 2 + } else if x < y { + (e.l(y - x - 2) + 2 * e.l(y - x - 1) + e.l(y - x) + 2) >> 2 + } else { + (e.t(0) + 2 * e.tl + e.l(0) + 2) >> 2 + } +} + +fn vertical_right(e: &Edge, x: i32, y: i32) -> i32 { + let z = 2 * x - y; + if z >= 0 && z % 2 == 0 { + (e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 1) >> 1 + } else if z > 0 { + (e.t(x - (y >> 1) - 2) + 2 * e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 2) >> 2 + } else if z == -1 { + (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 + } else { + (e.l(y - 2 * x - 1) + 2 * e.l(y - 2 * x - 2) + e.l(y - 2 * x - 3) + 2) >> 2 + } +} + +fn horizontal_down(e: &Edge, x: i32, y: i32) -> i32 { + let z = 2 * y - x; + if z >= 0 && z % 2 == 0 { + (e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 1) >> 1 + } else if z > 0 { + (e.l(y - (x >> 1) - 2) + 2 * e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 2) >> 2 + } else if z == -1 { + (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 + } else { + (e.t(x - 2 * y - 1) + 2 * e.t(x - 2 * y - 2) + e.t(x - 2 * y - 3) + 2) >> 2 + } +} + +fn vertical_left(e: &Edge, x: i32, y: i32) -> i32 { + let i = x + (y >> 1); + if y % 2 == 0 { (e.t(i) + e.t(i + 1) + 1) >> 1 } else { (e.t(i) + 2 * e.t(i + 1) + e.t(i + 2) + 2) >> 2 } +} + +fn horizontal_up(e: &Edge, n: i32, x: i32, y: i32) -> i32 { + let zmax = 2 * n - 3; + let z = x + 2 * y; + let i = y + (x >> 1); + if z < zmax && z % 2 == 0 { + (e.l(i) + e.l(i + 1) + 1) >> 1 + } else if z < zmax { + (e.l(i) + 2 * e.l(i + 1) + e.l(i + 2) + 2) >> 2 + } else if z == zmax { + (e.l(n - 2) + 3 * e.l(n - 1) + 2) >> 2 + } else { + e.l(n - 1) } } diff --git a/crates/h264/src/slice.rs b/crates/h264/src/slice.rs index e1cad290..23ae971e 100644 --- a/crates/h264/src/slice.rs +++ b/crates/h264/src/slice.rs @@ -142,6 +142,107 @@ pub struct SliceHeader { pub header_bits: usize, } +/// ref_pic_list_modification() for list 0 and list 1. +fn read_ref_pic_list_mods(r: &mut BitReader, slice_type: SliceType) -> Result<[Vec; 2]> { + let mut ref_pic_list_mod = [Vec::new(), Vec::new()]; + for (list, mods) in ref_pic_list_mod.iter_mut().enumerate() { + let present = if list == 0 { !slice_type.is_intra() } else { slice_type.is_b() }; + if present && r.read_flag()? { + loop { + let idc = r.read_ue()?; + ensure!(idc <= 5, "modification_of_pic_nums_idc out of range"); + if idc == 3 { + break; + } + let value = r.read_ue()?; + mods.push(RefPicListMod { idc, value }); + ensure!(mods.len() <= 66, "too many ref_pic_list_modification entries"); + } + } + } + Ok(ref_pic_list_mod) +} + +/// pred_weight_table(). +fn read_pred_weight_table(r: &mut BitReader, sps: &Sps, slice_type: SliceType, num_ref_idx_active: [u32; 2]) -> Result { + let luma_log2_denom = r.read_ue()?; + ensure!(luma_log2_denom <= 7, "luma_log2_weight_denom out of range"); + let has_chroma = sps.chroma_array_type() != 0; + let mut chroma_log2_denom = 0; + if has_chroma { + chroma_log2_denom = r.read_ue()?; + ensure!(chroma_log2_denom <= 7, "chroma_log2_weight_denom out of range"); + } + let mut lists = [Vec::new(), Vec::new()]; + let nlists = if slice_type.is_b() { 2 } else { 1 }; + for (l, entries) in lists.iter_mut().enumerate().take(nlists) { + for _ in 0..num_ref_idx_active[l] { + entries.push(read_weight_entry(r, has_chroma, luma_log2_denom, chroma_log2_denom)?); + } + } + let [l0, l1] = lists; + Ok(PredWeightTable { luma_log2_denom, chroma_log2_denom, l0, l1 }) +} + +fn read_weight_entry(r: &mut BitReader, has_chroma: bool, luma_log2_denom: u32, chroma_log2_denom: u32) -> Result { + let mut e = WeightEntry { luma_weight: 1 << luma_log2_denom, chroma_weight: [1 << chroma_log2_denom; 2], ..Default::default() }; + if r.read_flag()? { + e.luma_flag = true; + e.luma_weight = r.read_se()?; + e.luma_offset = r.read_se()?; + ensure!((-128..=127).contains(&e.luma_weight) && (-128..=127).contains(&e.luma_offset), "luma weight out of range"); + } + if has_chroma && r.read_flag()? { + e.chroma_flag = true; + for j in 0..2 { + e.chroma_weight[j] = r.read_se()?; + e.chroma_offset[j] = r.read_se()?; + ensure!((-128..=127).contains(&e.chroma_weight[j]) && (-128..=127).contains(&e.chroma_offset[j]), "chroma weight out of range"); + } + } + Ok(e) +} + +/// The memory_management_control_operation list of dec_ref_pic_marking(). +fn read_mmcos(r: &mut BitReader) -> Result> { + let mut mmcos = Vec::new(); + loop { + let op = r.read_ue()?; + ensure!(op <= 6, "memory_management_control_operation out of range"); + if op == 0 { + return Ok(mmcos); + } + let mut m = Mmco { op, difference_of_pic_nums_minus1: 0, long_term_pic_num: 0, long_term_frame_idx: 0, max_long_term_frame_idx_plus1: 0 }; + if op == 1 || op == 3 { + m.difference_of_pic_nums_minus1 = r.read_ue()?; + } + if op == 2 { + m.long_term_pic_num = r.read_ue()?; + } + if op == 3 || op == 6 { + m.long_term_frame_idx = r.read_ue()?; + } + if op == 4 { + m.max_long_term_frame_idx_plus1 = r.read_ue()?; + } + mmcos.push(m); + ensure!(mmcos.len() <= 66, "too many MMCOs"); + } +} + +/// Deblocking filter control: (disable_deblocking_filter_idc, alpha_c0_offset_div2, beta_offset_div2). +fn read_deblocking_params(r: &mut BitReader) -> Result<(u32, i32, i32)> { + let disable_deblocking_filter_idc = r.read_ue()?; + ensure!(disable_deblocking_filter_idc <= 2, "disable_deblocking_filter_idc out of range"); + if disable_deblocking_filter_idc == 1 { + return Ok((disable_deblocking_filter_idc, 0, 0)); + } + let alpha = r.read_se()?; + let beta = r.read_se()?; + ensure!((-6..=6).contains(&alpha) && (-6..=6).contains(&beta), "deblocking offsets out of range"); + Ok((disable_deblocking_filter_idc, alpha, beta)) +} + impl SliceHeader { pub fn qp(&self, pps: &Pps) -> i32 { pps.pic_init_qp.saturating_add(self.slice_qp_delta) @@ -214,59 +315,12 @@ impl SliceHeader { num_ref_idx_active[0] = 0; } ensure!(nal.nal_unit_type != 20 && nal.nal_unit_type != 21, "MVC slices are not supported"); - let mut ref_pic_list_mod = [Vec::new(), Vec::new()]; - for (list, mods) in ref_pic_list_mod.iter_mut().enumerate() { - let present = if list == 0 { !slice_type.is_intra() } else { slice_type.is_b() }; - if present && r.read_flag()? { - loop { - let idc = r.read_ue()?; - ensure!(idc <= 5, "modification_of_pic_nums_idc out of range"); - if idc == 3 { - break; - } - let value = r.read_ue()?; - mods.push(RefPicListMod { idc, value }); - ensure!(mods.len() <= 66, "too many ref_pic_list_modification entries"); - } - } - } - let mut pred_weight_table = None; - if (pps.weighted_pred && slice_type.is_p()) || (pps.weighted_bipred_idc == 1 && slice_type.is_b()) { - let luma_log2_denom = r.read_ue()?; - ensure!(luma_log2_denom <= 7, "luma_log2_weight_denom out of range"); - let mut chroma_log2_denom = 0; - if sps.chroma_array_type() != 0 { - chroma_log2_denom = r.read_ue()?; - ensure!(chroma_log2_denom <= 7, "chroma_log2_weight_denom out of range"); - } - let mut lists = [Vec::new(), Vec::new()]; - let nlists = if slice_type.is_b() { 2 } else { 1 }; - for (l, entries) in lists.iter_mut().enumerate().take(nlists) { - for _ in 0..num_ref_idx_active[l] { - let mut e = WeightEntry { luma_weight: 1 << luma_log2_denom, chroma_weight: [1 << chroma_log2_denom; 2], ..Default::default() }; - if r.read_flag()? { - e.luma_flag = true; - e.luma_weight = r.read_se()?; - e.luma_offset = r.read_se()?; - ensure!((-128..=127).contains(&e.luma_weight) && (-128..=127).contains(&e.luma_offset), "luma weight out of range"); - } - if sps.chroma_array_type() != 0 && r.read_flag()? { - e.chroma_flag = true; - for j in 0..2 { - e.chroma_weight[j] = r.read_se()?; - e.chroma_offset[j] = r.read_se()?; - ensure!( - (-128..=127).contains(&e.chroma_weight[j]) && (-128..=127).contains(&e.chroma_offset[j]), - "chroma weight out of range" - ); - } - } - entries.push(e); - } - } - let [l0, l1] = lists; - pred_weight_table = Some(PredWeightTable { luma_log2_denom, chroma_log2_denom, l0, l1 }); - } + let ref_pic_list_mod = read_ref_pic_list_mods(&mut r, slice_type)?; + let pred_weight_table = if (pps.weighted_pred && slice_type.is_p()) || (pps.weighted_bipred_idc == 1 && slice_type.is_b()) { + Some(read_pred_weight_table(&mut r, sps, slice_type, num_ref_idx_active)?) + } else { + None + }; let mut no_output_of_prior_pics = false; let mut long_term_reference = false; let mut adaptive_ref_pic_marking = false; @@ -278,34 +332,7 @@ impl SliceHeader { } else { adaptive_ref_pic_marking = r.read_flag()?; if adaptive_ref_pic_marking { - loop { - let op = r.read_ue()?; - ensure!(op <= 6, "memory_management_control_operation out of range"); - if op == 0 { - break; - } - let mut m = Mmco { - op, - difference_of_pic_nums_minus1: 0, - long_term_pic_num: 0, - long_term_frame_idx: 0, - max_long_term_frame_idx_plus1: 0, - }; - if op == 1 || op == 3 { - m.difference_of_pic_nums_minus1 = r.read_ue()?; - } - if op == 2 { - m.long_term_pic_num = r.read_ue()?; - } - if op == 3 || op == 6 { - m.long_term_frame_idx = r.read_ue()?; - } - if op == 4 { - m.max_long_term_frame_idx_plus1 = r.read_ue()?; - } - mmcos.push(m); - ensure!(mmcos.len() <= 66, "too many MMCOs"); - } + mmcos = read_mmcos(&mut r)?; } } } @@ -325,21 +352,8 @@ impl SliceHeader { } slice_qs_delta = r.read_se()?; } - let mut disable_deblocking_filter_idc = 0; - let mut slice_alpha_c0_offset_div2 = 0; - let mut slice_beta_offset_div2 = 0; - if pps.deblocking_filter_control_present { - disable_deblocking_filter_idc = r.read_ue()?; - ensure!(disable_deblocking_filter_idc <= 2, "disable_deblocking_filter_idc out of range"); - if disable_deblocking_filter_idc != 1 { - slice_alpha_c0_offset_div2 = r.read_se()?; - slice_beta_offset_div2 = r.read_se()?; - ensure!( - (-6..=6).contains(&slice_alpha_c0_offset_div2) && (-6..=6).contains(&slice_beta_offset_div2), - "deblocking offsets out of range" - ); - } - } + let (disable_deblocking_filter_idc, slice_alpha_c0_offset_div2, slice_beta_offset_div2) = + if pps.deblocking_filter_control_present { read_deblocking_params(&mut r)? } else { (0, 0, 0) }; let mut slice_group_change_cycle = 0; if pps.num_slice_groups > 1 && (3..=5).contains(&pps.slice_group_map_type) { // Ceil(Log2(PicSizeInMapUnits / SliceGroupChangeRate + 1)); rate is not retained, so read diff --git a/crates/hevc/src/slice.rs b/crates/hevc/src/slice.rs index d6fc439a..aadf6159 100644 --- a/crates/hevc/src/slice.rs +++ b/crates/hevc/src/slice.rs @@ -154,6 +154,194 @@ impl SliceHeader { (st + lt) as u32 } + /// Fields present only in independent slice segments. + fn parse_independent_fields(&mut self, r: &mut BitReader, nal: NalHeader, pps: &Pps, sps: &Sps) -> Result<()> { + r.skip(pps.num_extra_slice_header_bits as usize)?; + let st = r.read_ue()?; + self.slice_type = match st { + 0 => SliceType::B, + 1 => SliceType::P, + 2 => SliceType::I, + _ => return invalid(format!("slice_type {st}")), + }; + if nal.is_irap() { + ensure!(self.slice_type == SliceType::I, "IRAP picture with inter slice"); + } + if pps.output_flag_present { + self.pic_output = r.read_flag()?; + } + if sps.separate_colour_plane { + r.skip(2)?; + } + if !nal.is_idr() { + self.parse_reference_picture_set(r, sps)?; + } + if sps.sao { + self.sao_luma = r.read_flag()?; + if sps.chroma_array_type() != 0 { + self.sao_chroma = r.read_flag()?; + } + } + if self.slice_type != SliceType::I { + self.parse_inter_params(r, pps, sps)?; + } + self.parse_qp_and_deblocking(r, pps, sps) + } + + /// POC LSB, short-term and long-term reference picture sets and the temporal MVP flag. + fn parse_reference_picture_set(&mut self, r: &mut BitReader, sps: &Sps) -> Result<()> { + self.poc_lsb = r.read_bits(sps.log2_max_poc_lsb)?; + if !r.read_flag()? { + let start = r.position(); + self.st_rps = StRps::parse(r, sps.st_rps.len(), &sps.st_rps, sps.st_rps.len())?; + self.st_rps_bits = r.position() - start; + } else { + ensure!(!sps.st_rps.is_empty(), "no short-term RPS in the SPS"); + let idx = if sps.st_rps.len() > 1 { r.read_bits(ceil_log2(sps.st_rps.len() as u32))? as usize } else { 0 }; + ensure!(idx < sps.st_rps.len(), "short_term_ref_pic_set_idx out of range"); + self.st_rps = sps.st_rps[idx].clone(); + } + if sps.long_term_refs_present { + self.parse_long_term_pics(r, sps)?; + } + if sps.temporal_mvp { + self.temporal_mvp = r.read_flag()?; + } + Ok(()) + } + + fn parse_long_term_pics(&mut self, r: &mut BitReader, sps: &Sps) -> Result<()> { + let num_lt_sps = if !sps.lt_ref_pics.is_empty() { r.read_ue()? } else { 0 }; + ensure!(num_lt_sps as usize <= sps.lt_ref_pics.len(), "num_long_term_sps out of range"); + let num_lt_pics = r.read_ue()?; + ensure!(num_lt_sps + num_lt_pics <= 32, "too many long-term pictures"); + let mut msb_acc = 0u32; + for i in 0..num_lt_sps + num_lt_pics { + let (lsb, used) = if i < num_lt_sps { + let idx = if sps.lt_ref_pics.len() > 1 { r.read_bits(ceil_log2(sps.lt_ref_pics.len() as u32))? } else { 0 }; + ensure!((idx as usize) < sps.lt_ref_pics.len(), "lt_idx_sps out of range"); + sps.lt_ref_pics[idx as usize] + } else { + (r.read_bits(sps.log2_max_poc_lsb)?, r.read_flag()?) + }; + let msb_present = r.read_flag()?; + let cycle = if msb_present { r.read_ue()? } else { 0 }; + // DeltaPocMsbCycleLt accumulates within each of the two groups (7-52) + if i == 0 || i == num_lt_sps { + msb_acc = cycle; + } else { + msb_acc = msb_acc.wrapping_add(cycle); + } + self.lt.push((lsb, used, msb_present, msb_acc)); + } + Ok(()) + } + + /// Reference index counts, list modification, collocated picture, prediction weights and merge candidates (P and B slices). + fn parse_inter_params(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> { + self.num_ref_idx = [pps.num_ref_idx_l0_default, if self.is_b() { pps.num_ref_idx_l1_default } else { 0 }]; + if r.read_flag()? { + self.num_ref_idx[0] = r.read_ue_plus(1)?; + if self.is_b() { + self.num_ref_idx[1] = r.read_ue_plus(1)?; + } + } + ensure!(self.num_ref_idx[0] <= 15 && self.num_ref_idx[1] <= 15, "num_ref_idx_active out of range"); + let total = self.num_pic_total_curr(); + ensure!(total > 0, "inter slice without reference pictures in the RPS"); + if pps.lists_modification_present && total > 1 { + self.parse_list_entries(r, total)?; + } + if self.is_b() { + self.mvd_l1_zero = r.read_flag()?; + } + if pps.cabac_init_present { + self.cabac_init_flag = r.read_flag()?; + } + if self.temporal_mvp { + self.parse_collocated(r)?; + } + if (pps.weighted_pred && self.slice_type == SliceType::P) || (pps.weighted_bipred && self.is_b()) { + self.pwt = Some(parse_pwt(r, self, sps)?); + } + let five_minus = r.read_ue()?; + ensure!(five_minus <= 4, "five_minus_max_num_merge_cand out of range"); + self.max_num_merge_cand = 5 - five_minus; + Ok(()) + } + + fn parse_list_entries(&mut self, r: &mut BitReader, total: u32) -> Result<()> { + let bits = ceil_log2(total); + for l in 0..if self.is_b() { 2 } else { 1 } { + if r.read_flag()? { + let mut e = Vec::new(); + for _ in 0..self.num_ref_idx[l] { + let v = r.read_bits(bits)?; + ensure!(v < total, "list_entry out of range"); + e.push(v); + } + self.list_entry[l] = Some(e); + } + } + Ok(()) + } + + fn parse_collocated(&mut self, r: &mut BitReader) -> Result<()> { + if self.is_b() { + self.collocated_from_l0 = r.read_flag()?; + } + let l = if self.collocated_from_l0 { 0 } else { 1 }; + if self.num_ref_idx[l] > 1 { + self.collocated_ref_idx = r.read_ue()?; + ensure!(self.collocated_ref_idx < self.num_ref_idx[l], "collocated_ref_idx out of range"); + } + Ok(()) + } + + /// Slice QP delta, chroma QP offsets and the deblocking / loop filter overrides. + fn parse_qp_and_deblocking(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> { + self.qp_delta = r.read_se()?; + let qp = pps.init_qp.checked_add(self.qp_delta).ok_or_else(|| crate::Error::Invalid("SliceQpY out of range".into()))?; + let qp_bd = 6 * (sps.bit_depth_luma as i32 - 8); + ensure!((-qp_bd..=51).contains(&qp), "SliceQpY {qp} out of range"); + if pps.slice_chroma_qp_offsets_present { + self.cb_qp_offset = r.read_se()?; + self.cr_qp_offset = r.read_se()?; + ensure!((-12..=12).contains(&self.cb_qp_offset) && (-12..=12).contains(&self.cr_qp_offset), "slice chroma QP offset out of range"); + } + let override_flag = if pps.deblocking_override_enabled { r.read_flag()? } else { false }; + if override_flag { + self.deblocking_disabled = r.read_flag()?; + if !self.deblocking_disabled { + self.beta_offset_div2 = r.read_se()?; + self.tc_offset_div2 = r.read_se()?; + ensure!((-6..=6).contains(&self.beta_offset_div2) && (-6..=6).contains(&self.tc_offset_div2), "deblocking offsets out of range"); + } + } + if pps.loop_filter_across_slices && (self.sao_luma || self.sao_chroma || !self.deblocking_disabled) { + self.loop_filter_across_slices = r.read_flag()?; + } + Ok(()) + } + + /// entry_point_offset list (tiles / wavefront substreams). + fn parse_entry_points(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> { + self.entry_points.clear(); + if pps.tiles_enabled || pps.entropy_coding_sync { + let n = r.read_ue()?; + ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range"); + if n > 0 { + let len = r.read_ue_plus(1)?; + ensure!(len <= 32, "offset_len_minus1 out of range"); + for _ in 0..n { + let off = r.read_bits(len)?.checked_add(1).ok_or_else(|| crate::Error::Invalid("entry_point_offset out of range".into()))?; + self.entry_points.push(off); + } + } + } + Ok(()) + } + /// Parse a slice segment header. `prev` is the header of the preceding independent slice segment /// of the same picture (needed for dependent slice segments). pub fn parse( @@ -225,154 +413,9 @@ impl SliceHeader { sh.dependent = dependent; sh.segment_address = segment_address; if !dependent { - r.skip(pps.num_extra_slice_header_bits as usize)?; - let st = r.read_ue()?; - sh.slice_type = match st { - 0 => SliceType::B, - 1 => SliceType::P, - 2 => SliceType::I, - _ => return invalid(format!("slice_type {st}")), - }; - if nal.is_irap() { - ensure!(sh.slice_type == SliceType::I, "IRAP picture with inter slice"); - } - if pps.output_flag_present { - sh.pic_output = r.read_flag()?; - } - if sps.separate_colour_plane { - r.skip(2)?; - } - if !nal.is_idr() { - sh.poc_lsb = r.read_bits(sps.log2_max_poc_lsb)?; - let from_sps = r.read_flag()?; - if !from_sps { - let start = r.position(); - sh.st_rps = StRps::parse(&mut r, sps.st_rps.len(), &sps.st_rps, sps.st_rps.len())?; - sh.st_rps_bits = r.position() - start; - } else { - ensure!(!sps.st_rps.is_empty(), "no short-term RPS in the SPS"); - let idx = if sps.st_rps.len() > 1 { r.read_bits(ceil_log2(sps.st_rps.len() as u32))? as usize } else { 0 }; - ensure!(idx < sps.st_rps.len(), "short_term_ref_pic_set_idx out of range"); - sh.st_rps = sps.st_rps[idx].clone(); - } - if sps.long_term_refs_present { - let num_lt_sps = if !sps.lt_ref_pics.is_empty() { r.read_ue()? } else { 0 }; - ensure!(num_lt_sps as usize <= sps.lt_ref_pics.len(), "num_long_term_sps out of range"); - let num_lt_pics = r.read_ue()?; - ensure!(num_lt_sps + num_lt_pics <= 32, "too many long-term pictures"); - let mut msb_acc = 0u32; - for i in 0..num_lt_sps + num_lt_pics { - let (lsb, used) = if i < num_lt_sps { - let idx = if sps.lt_ref_pics.len() > 1 { r.read_bits(ceil_log2(sps.lt_ref_pics.len() as u32))? } else { 0 }; - ensure!((idx as usize) < sps.lt_ref_pics.len(), "lt_idx_sps out of range"); - sps.lt_ref_pics[idx as usize] - } else { - (r.read_bits(sps.log2_max_poc_lsb)?, r.read_flag()?) - }; - let msb_present = r.read_flag()?; - let cycle = if msb_present { r.read_ue()? } else { 0 }; - // DeltaPocMsbCycleLt accumulates within each of the two groups (7-52) - if i == 0 || i == num_lt_sps { - msb_acc = cycle; - } else { - msb_acc = msb_acc.wrapping_add(cycle); - } - sh.lt.push((lsb, used, msb_present, msb_acc)); - } - } - if sps.temporal_mvp { - sh.temporal_mvp = r.read_flag()?; - } - } - if sps.sao { - sh.sao_luma = r.read_flag()?; - if sps.chroma_array_type() != 0 { - sh.sao_chroma = r.read_flag()?; - } - } - if sh.slice_type != SliceType::I { - sh.num_ref_idx = [pps.num_ref_idx_l0_default, if sh.is_b() { pps.num_ref_idx_l1_default } else { 0 }]; - if r.read_flag()? { - sh.num_ref_idx[0] = r.read_ue_plus(1)?; - if sh.is_b() { - sh.num_ref_idx[1] = r.read_ue_plus(1)?; - } - } - ensure!(sh.num_ref_idx[0] <= 15 && sh.num_ref_idx[1] <= 15, "num_ref_idx_active out of range"); - let total = sh.num_pic_total_curr(); - ensure!(total > 0, "inter slice without reference pictures in the RPS"); - if pps.lists_modification_present && total > 1 { - let bits = ceil_log2(total); - for l in 0..if sh.is_b() { 2 } else { 1 } { - if r.read_flag()? { - let mut e = Vec::new(); - for _ in 0..sh.num_ref_idx[l] { - let v = r.read_bits(bits)?; - ensure!(v < total, "list_entry out of range"); - e.push(v); - } - sh.list_entry[l] = Some(e); - } - } - } - if sh.is_b() { - sh.mvd_l1_zero = r.read_flag()?; - } - if pps.cabac_init_present { - sh.cabac_init_flag = r.read_flag()?; - } - if sh.temporal_mvp { - if sh.is_b() { - sh.collocated_from_l0 = r.read_flag()?; - } - let l = if sh.collocated_from_l0 { 0 } else { 1 }; - if sh.num_ref_idx[l] > 1 { - sh.collocated_ref_idx = r.read_ue()?; - ensure!(sh.collocated_ref_idx < sh.num_ref_idx[l], "collocated_ref_idx out of range"); - } - } - if (pps.weighted_pred && sh.slice_type == SliceType::P) || (pps.weighted_bipred && sh.is_b()) { - sh.pwt = Some(parse_pwt(&mut r, &sh, &sps)?); - } - let five_minus = r.read_ue()?; - ensure!(five_minus <= 4, "five_minus_max_num_merge_cand out of range"); - sh.max_num_merge_cand = 5 - five_minus; - } - sh.qp_delta = r.read_se()?; - let qp = pps.init_qp.checked_add(sh.qp_delta).ok_or_else(|| crate::Error::Invalid("SliceQpY out of range".into()))?; - let qp_bd = 6 * (sps.bit_depth_luma as i32 - 8); - ensure!((-qp_bd..=51).contains(&qp), "SliceQpY {qp} out of range"); - if pps.slice_chroma_qp_offsets_present { - sh.cb_qp_offset = r.read_se()?; - sh.cr_qp_offset = r.read_se()?; - ensure!((-12..=12).contains(&sh.cb_qp_offset) && (-12..=12).contains(&sh.cr_qp_offset), "slice chroma QP offset out of range"); - } - let override_flag = if pps.deblocking_override_enabled { r.read_flag()? } else { false }; - if override_flag { - sh.deblocking_disabled = r.read_flag()?; - if !sh.deblocking_disabled { - sh.beta_offset_div2 = r.read_se()?; - sh.tc_offset_div2 = r.read_se()?; - ensure!((-6..=6).contains(&sh.beta_offset_div2) && (-6..=6).contains(&sh.tc_offset_div2), "deblocking offsets out of range"); - } - } - if pps.loop_filter_across_slices && (sh.sao_luma || sh.sao_chroma || !sh.deblocking_disabled) { - sh.loop_filter_across_slices = r.read_flag()?; - } - } - sh.entry_points.clear(); - if pps.tiles_enabled || pps.entropy_coding_sync { - let n = r.read_ue()?; - ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range"); - if n > 0 { - let len = r.read_ue_plus(1)?; - ensure!(len <= 32, "offset_len_minus1 out of range"); - for _ in 0..n { - let off = r.read_bits(len)?.checked_add(1).ok_or_else(|| crate::Error::Invalid("entry_point_offset out of range".into()))?; - sh.entry_points.push(off); - } - } + sh.parse_independent_fields(&mut r, nal, &pps, &sps)?; } + sh.parse_entry_points(&mut r, &pps, &sps)?; if pps.slice_header_extension_present { let len = r.read_ue()?; ensure!(len <= 256, "slice_segment_header_extension_length out of range"); diff --git a/crates/hevc/src/slicedec.rs b/crates/hevc/src/slicedec.rs index 6cc24cdc..e870b686 100644 --- a/crates/hevc/src/slicedec.rs +++ b/crates/hevc/src/slicedec.rs @@ -1105,7 +1105,52 @@ impl<'a> SliceDecoder<'a> { let n = 1usize << log2; let bypass = self.cu_transquant_bypass; let ts = if self.pps.transform_skip && !bypass && log2 == 2 { self.dd(TRANSFORM_SKIP + (c > 0) as usize) == 1 } else { false }; - // last significant coefficient + let scan_idx = self.scan_idx(x0, y0, log2, c); + let (mut lx, mut ly) = self.last_sig_coeff_pos(log2, c); + if scan_idx == 2 { + std::mem::swap(&mut lx, &mut ly); + } + ensure!((lx as usize) < n && (ly as usize) < n, "last significant coefficient outside the block"); + let t = TbCtx { c, log2, scan_idx, sdh: self.pps.sign_data_hiding && !bypass }; + let log2sb = log2 - 2; + let sb_scan = scan_order(log2sb, scan_idx); + let pos_scan = &SCAN_4[scan_idx]; + let sb_w = 1usize << log2sb; + let last_sb = sb_scan.iter().position(|&(x, y)| x as u32 == lx >> 2 && y as u32 == ly >> 2).unwrap_or(0); + let last_pos = pos_scan.iter().position(|&(x, y)| x as u32 == lx & 3 && y as u32 == ly & 3).unwrap_or(0); + self.coeffs[..n * n].fill(0); + let mut csbf = [0u8; 64]; + let mut prev_g1: Option = None; + let (mut max_x, mut max_y) = (0usize, 0usize); + for i in (0..=last_sb).rev() { + let sb = SubBlock { xs: sb_scan[i].0 as usize, ys: sb_scan[i].1 as usize }; + let right = if sb.xs + 1 < sb_w { csbf[sb.ys * 8 + sb.xs + 1] } else { 0 }; + let below = if sb.ys + 1 < sb_w { csbf[(sb.ys + 1) * 8 + sb.xs] } else { 0 }; + let mut infer_dc = false; + if i < last_sb && i > 0 { + let inc = (right | below).min(1) as usize + if c > 0 { 2 } else { 0 }; + csbf[sb.ys * 8 + sb.xs] = self.c.decode_decision(CODED_SUB_BLOCK + inc) as u8; + infer_dc = true; + } else { + csbf[sb.ys * 8 + sb.xs] = 1; + } + if csbf[sb.ys * 8 + sb.xs] == 0 { + continue; + } + let (sig, nsig) = self.sig_coeff_flags(&t, &sb, right + (below << 1), infer_dc, (i == last_sb).then_some(last_pos)); + if nsig == 0 { + continue; + } + let g = self.greater_flags(&t, i, nsig, &mut prev_g1); + let (mx, my) = self.store_levels(&t, &sb, &sig[..nsig], &g)?; + max_x = max_x.max(mx); + max_y = max_y.max(my); + } + self.reconstruct((x0, y0), log2, c, qp, ts, (max_x, max_y)) + } + + /// Decodes last_sig_coeff_{x,y}_prefix / suffix (before any scan-order swap). + fn last_sig_coeff_pos(&mut self, log2: u32, c: usize) -> (u32, u32) { let cmax = (log2 << 1) - 1; let (off, shift) = if c == 0 { (3 * (log2 - 2) + ((log2 - 1) >> 2), (log2 + 1) >> 2) } else { (15, log2 - 2) }; let mut px = 0; @@ -1116,209 +1161,144 @@ impl<'a> SliceDecoder<'a> { while py < cmax && self.dd(LAST_Y_PREFIX + (off + (py >> shift)) as usize) == 1 { py += 1; } - let mut lx = px; - if px > 3 { - let nb = (px >> 1) - 1; - lx = (1 << nb) * (2 + (px & 1)) + self.bypass_bits(nb); + let lx = self.last_pos_with_suffix(px); + let ly = self.last_pos_with_suffix(py); + (lx, ly) + } + + fn last_pos_with_suffix(&mut self, prefix: u32) -> u32 { + if prefix > 3 { + let nb = (prefix >> 1) - 1; + (1 << nb) * (2 + (prefix & 1)) + self.bypass_bits(nb) + } else { + prefix } - let mut ly = py; - if py > 3 { - let nb = (py >> 1) - 1; - ly = (1 << nb) * (2 + (py & 1)) + self.bypass_bits(nb); + } + + /// scanIdx (7.4.9.11): 0 diagonal, 1 horizontal, 2 vertical. + fn scan_idx(&self, x0: i32, y0: i32, log2: u32, c: usize) -> usize { + if !(self.cu_intra && (log2 == 2 || (log2 == 3 && c == 0))) { + return 0; } - // scanIdx (7.4.9.11) - let scan_idx = if self.cu_intra && (log2 == 2 || (log2 == 3 && c == 0)) { - let m = if c == 0 { self.pic.blk_at(x0, y0).ipm as u32 } else { self.intra_chroma_mode }; - if (6..=14).contains(&m) { - 2 - } else if (22..=30).contains(&m) { - 1 - } else { - 0 - } + let m = if c == 0 { self.pic.blk_at(x0, y0).ipm as u32 } else { self.intra_chroma_mode }; + if (6..=14).contains(&m) { + 2 + } else if (22..=30).contains(&m) { + 1 } else { 0 - }; - if scan_idx == 2 { - std::mem::swap(&mut lx, &mut ly); } - ensure!((lx as usize) < n && (ly as usize) < n, "last significant coefficient outside the block"); - let log2sb = log2 - 2; - let sb_scan = scan_order(log2sb, scan_idx); - let pos_scan = &SCAN_4[scan_idx]; - let sb_w = 1usize << log2sb; - let last_sb = sb_scan.iter().position(|&(x, y)| x as u32 == lx >> 2 && y as u32 == ly >> 2).unwrap_or(0); - let last_pos = pos_scan.iter().position(|&(x, y)| x as u32 == lx & 3 && y as u32 == ly & 3).unwrap_or(0); - self.coeffs[..n * n].fill(0); - let mut csbf = [0u8; 64]; - let mut prev_g1: Option = None; - let sdh = self.pps.sign_data_hiding && !bypass; - let (mut max_x, mut max_y) = (0usize, 0usize); - let c_off_sig = if c == 0 { 0 } else { 27 }; - for i in (0..=last_sb).rev() { - let (xs, ys) = (sb_scan[i].0 as usize, sb_scan[i].1 as usize); - let mut infer_dc = false; - let right = if xs + 1 < sb_w { csbf[ys * 8 + xs + 1] } else { 0 }; - let below = if ys + 1 < sb_w { csbf[(ys + 1) * 8 + xs] } else { 0 }; - if i < last_sb && i > 0 { - let inc = (right | below).min(1) as usize + if c > 0 { 2 } else { 0 }; - csbf[ys * 8 + xs] = self.c.decode_decision(CODED_SUB_BLOCK + inc) as u8; - infer_dc = true; - } else { - csbf[ys * 8 + xs] = 1; - } - // significance map - let mut sig = [0u8; 16]; // scan positions n of significant coefficients, descending - let mut nsig = 0; - let start: i32 = if i == last_sb { - sig[0] = last_pos as u8; + } + + /// Significance map of one coded sub-block: scan positions of the significant coefficients, in + /// descending order, and their count. + fn sig_coeff_flags(&mut self, t: &TbCtx, sb: &SubBlock, prev_csbf: u8, infer_dc: bool, last_pos: Option) -> ([u8; 16], usize) { + let pos_scan = &SCAN_4[t.scan_idx]; + let c_off_sig = if t.c == 0 { 0 } else { 27 }; + let mut infer_dc = infer_dc; + let mut sig = [0u8; 16]; + let mut nsig = 0; + let start: i32 = match last_pos { + Some(lp) => { + sig[0] = lp as u8; nsig = 1; - last_pos as i32 - 1 + lp as i32 - 1 + } + None => 15, + }; + for np in (0..=start).rev() { + let (xp, yp) = (pos_scan[np as usize].0 as usize, pos_scan[np as usize].1 as usize); + if np > 0 || !infer_dc { + let ctx = sig_coeff_ctx(t, sb, prev_csbf, (xp, yp)); + if self.c.decode_decision(SIG_COEFF + c_off_sig + ctx) == 1 { + sig[nsig] = np as u8; + nsig += 1; + infer_dc = false; + } } else { - 15 - }; - if csbf[ys * 8 + xs] != 0 { - let prev_csbf = right + (below << 1); - let mut np = start; - while np >= 0 { - let (xp, yp) = (pos_scan[np as usize].0 as usize, pos_scan[np as usize].1 as usize); - let (xc, yc) = (xs * 4 + xp, ys * 4 + yp); - if np > 0 || !infer_dc { - let sig_ctx = if log2 == 2 { - CTX_IDX_MAP[(yc << 2) + xc] as usize - } else if xc + yc == 0 { - 0 - } else { - let mut s = match prev_csbf { - 0 => { - if xp + yp == 0 { - 2 - } else if xp + yp < 3 { - 1 - } else { - 0 - } - } - 1 => { - if yp == 0 { - 2 - } else if yp == 1 { - 1 - } else { - 0 - } - } - 2 => { - if xp == 0 { - 2 - } else if xp == 1 { - 1 - } else { - 0 - } - } - _ => 2, - }; - if c == 0 { - if xs + ys > 0 { - s += 3; - } - s += if log2 == 3 { if scan_idx == 0 { 9 } else { 15 } } else { 21 }; - } else { - s += if log2 == 3 { 9 } else { 12 }; - } - s - }; - if self.c.decode_decision(SIG_COEFF + c_off_sig + sig_ctx) == 1 { - sig[nsig] = np as u8; - nsig += 1; - infer_dc = false; - } - } else { - // np == 0 with inferSbDcSigCoeffFlag - sig[nsig] = 0; - nsig += 1; - } - np -= 1; - } - } - if nsig == 0 { - continue; - } - // greater1 / greater2 flags - let mut ctx_set = if i == 0 || c > 0 { 0 } else { 2 }; - if let Some(g) = prev_g1 - && g == 0 - { - ctx_set += 1; - } - let mut g1ctx = 1u32; - let mut gt1 = [0u8; 16]; - let mut first_g1: Option = None; - let c_off_g1 = if c > 0 { 16 } else { 0 }; - for k in 0..nsig.min(8) { - let inc = ctx_set * 4 + g1ctx.min(3) as usize + c_off_g1; - let f = self.c.decode_decision(GT1 + inc); - gt1[k] = f as u8; - if f == 1 { - g1ctx = 0; - if first_g1.is_none() { - first_g1 = Some(k); - } - } else if g1ctx > 0 { - g1ctx += 1; - } - } - prev_g1 = Some(g1ctx); - let mut gt2 = 0u8; - if let Some(k) = first_g1 { - gt2 = self.c.decode_decision(GT2 + ctx_set + if c > 0 { 4 } else { 0 }) as u8; - let _ = k; - } - // signs - let last_sig_pos = sig[0] as i32; - let first_sig_pos = sig[nsig - 1] as i32; - let sign_hidden = sdh && last_sig_pos - first_sig_pos > 3; - let nsigns = if sign_hidden { nsig - 1 } else { nsig }; - let mut signs = if nsigns > 0 { self.bypass_bits(nsigns as u32) << (32 - nsigns) } else { 0 }; - // remaining levels - let mut rice = 0u32; - let mut sum_abs = 0i32; - for k in 0..nsig { - let mut base = 1 + gt1[k] as i32; - if Some(k) == first_g1 { - base += gt2 as i32; - } - let thresh = if k < 8 { if Some(k) == first_g1 { 3 } else { 2 } } else { 1 }; - let mut level = base; - if base == thresh { - let rem = self.coeff_abs_level_remaining(rice)?; - level = base + rem; - if level > 3 * (1 << rice) { - rice = (rice + 1).min(4); - } - } - let np = sig[k] as usize; - let (xc, yc) = (xs * 4 + pos_scan[np].0 as usize, ys * 4 + pos_scan[np].1 as usize); - let mut v = level; - if !(sign_hidden && k == nsig - 1) { - if signs & 0x8000_0000 != 0 { - v = -v; - } - signs <<= 1; - } - if sign_hidden { - sum_abs += level; - if k == nsig - 1 && sum_abs & 1 == 1 { - v = -v; - } - } - self.coeffs[yc * n + xc] = v; - max_x = max_x.max(xc); - max_y = max_y.max(yc); + // np == 0 with inferSbDcSigCoeffFlag + sig[nsig] = 0; + nsig += 1; } } - self.reconstruct((x0, y0), log2, c, qp, ts, (max_x, max_y)) + (sig, nsig) + } + + /// coeff_abs_level_greater1_flag / greater2_flag of one sub-block with `nsig` significant coefficients. + fn greater_flags(&mut self, t: &TbCtx, sb_index: usize, nsig: usize, prev_g1: &mut Option) -> GreaterFlags { + let mut ctx_set = if sb_index == 0 || t.c > 0 { 0 } else { 2 }; + if *prev_g1 == Some(0) { + ctx_set += 1; + } + let mut g1ctx = 1u32; + let mut g = GreaterFlags { gt1: [0u8; 16], first_g1: None, gt2: 0 }; + let c_off_g1 = if t.c > 0 { 16 } else { 0 }; + for k in 0..nsig.min(8) { + let inc = ctx_set * 4 + g1ctx.min(3) as usize + c_off_g1; + let f = self.c.decode_decision(GT1 + inc); + g.gt1[k] = f as u8; + if f == 1 { + g1ctx = 0; + if g.first_g1.is_none() { + g.first_g1 = Some(k); + } + } else if g1ctx > 0 { + g1ctx += 1; + } + } + *prev_g1 = Some(g1ctx); + if g.first_g1.is_some() { + g.gt2 = self.c.decode_decision(GT2 + ctx_set + if t.c > 0 { 4 } else { 0 }) as u8; + } + g + } + + /// Reads signs and remaining levels of one sub-block and stores the coefficients. Returns the + /// largest x and y coefficient position written. + fn store_levels(&mut self, t: &TbCtx, sb: &SubBlock, sig: &[u8], g: &GreaterFlags) -> Result<(usize, usize)> { + let nsig = sig.len(); + let n = 1usize << t.log2; + let pos_scan = &SCAN_4[t.scan_idx]; + let sign_hidden = t.sdh && sig[0] as i32 - sig[nsig - 1] as i32 > 3; + let nsigns = if sign_hidden { nsig - 1 } else { nsig }; + let mut signs = if nsigns > 0 { self.bypass_bits(nsigns as u32) << (32 - nsigns) } else { 0 }; + let mut rice = 0u32; + let mut sum_abs = 0i32; + let (mut max_x, mut max_y) = (0usize, 0usize); + for k in 0..nsig { + let mut base = 1 + g.gt1[k] as i32; + if Some(k) == g.first_g1 { + base += g.gt2 as i32; + } + let thresh = if k < 8 { if Some(k) == g.first_g1 { 3 } else { 2 } } else { 1 }; + let mut level = base; + if base == thresh { + let rem = self.coeff_abs_level_remaining(rice)?; + level = base + rem; + if level > 3 * (1 << rice) { + rice = (rice + 1).min(4); + } + } + let np = sig[k] as usize; + let (xc, yc) = (sb.xs * 4 + pos_scan[np].0 as usize, sb.ys * 4 + pos_scan[np].1 as usize); + let mut v = level; + if !(sign_hidden && k == nsig - 1) { + if signs & 0x8000_0000 != 0 { + v = -v; + } + signs <<= 1; + } + if sign_hidden { + sum_abs += level; + if k == nsig - 1 && sum_abs & 1 == 1 { + v = -v; + } + } + self.coeffs[yc * n + xc] = v; + max_x = max_x.max(xc); + max_y = max_y.max(yc); + } + Ok((max_x, max_y)) } fn coeff_abs_level_remaining(&mut self, rice: u32) -> Result { @@ -1565,3 +1545,71 @@ impl<'a> SliceDecoder<'a> { } } } + +/// Per-transform-block parameters of residual coding. +struct TbCtx { + c: usize, + log2: u32, + scan_idx: usize, + sdh: bool, +} + +/// Position of a 4x4 sub-block in the sub-block grid. +struct SubBlock { + xs: usize, + ys: usize, +} + +/// Greater-than-1 / greater-than-2 flags of one sub-block. +struct GreaterFlags { + gt1: [u8; 16], + first_g1: Option, + gt2: u8, +} + +/// sigCtx (9.3.4.2.5) for the coefficient at (`xp`, `yp`) inside the sub-block. +fn sig_coeff_ctx(t: &TbCtx, sb: &SubBlock, prev_csbf: u8, (xp, yp): (usize, usize)) -> usize { + let (xc, yc) = (sb.xs * 4 + xp, sb.ys * 4 + yp); + if t.log2 == 2 { + CTX_IDX_MAP[(yc << 2) + xc] as usize + } else if xc + yc == 0 { + 0 + } else { + sig_ctx_neighbourhood(prev_csbf, xp, yp) + sig_ctx_offset(t, sb.xs + sb.ys > 0) + } +} + +/// Base sigCtx from the coded-sub-block flags of the right and below neighbours. +fn sig_ctx_neighbourhood(prev_csbf: u8, xp: usize, yp: usize) -> usize { + let near = |v: usize| match v { + 0 => 2, + 1 => 1, + _ => 0, + }; + match prev_csbf { + 0 => { + if xp + yp == 0 { + 2 + } else if xp + yp < 3 { + 1 + } else { + 0 + } + } + 1 => near(yp), + 2 => near(xp), + _ => 2, + } +} + +/// Offset added to sigCtx for the block size, colour component and sub-block position. +fn sig_ctx_offset(t: &TbCtx, not_first_sb: bool) -> usize { + if t.c == 0 { + let size_off = if t.log2 == 3 { if t.scan_idx == 0 { 9 } else { 15 } } else { 21 }; + size_off + if not_first_sb { 3 } else { 0 } + } else if t.log2 == 3 { + 9 + } else { + 12 + } +} diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index a4dcd4c2..62e8f714 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -286,10 +286,20 @@ impl Plus { if !self.active { return None; } + match typ { + 0x4008 => self.object(flags, r), + 0x4009 | 0x4013 | 0x401A..=0x401C | 0x4036 => self.record_image_text_region(cv, typ, flags, r), + 0x4025..=0x4035 => self.record_state(cv, typ, flags, r), + 0x4010..=0x4012 | 0x4016..=0x4019 => self.record_curve(cv, typ, flags, r), + _ => self.record_shape(cv, typ, flags, r), + } + } + + /// Rectangle, polygon, ellipse and path records. + fn record_shape(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { let compressed = flags & 0x4000 != 0; let id = usize::from(flags & 0xFF); match typ { - 0x4008 => self.object(flags, r), 0x400A => { let fill = self.brush_of(flags, r.u32(0)?)?; let p = self.painter(); @@ -348,6 +358,15 @@ impl Plus { self.painter().stroke(cv, &ops, pen); Some(()) } + _ => None, + } + } + + /// Curve, bezier and arc records. + fn record_curve(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + let compressed = flags & 0x4000 != 0; + let id = usize::from(flags & 0xFF); + match typ { 0x4019 => { let pen = self.pen(id)?; let pts = points(r, 4, r.u32(0)? as usize, compressed)?; @@ -380,6 +399,29 @@ impl Plus { self.painter().stroke(cv, &curve_ops(&pts, tension, false, offset, segments), pen); Some(()) } + 0x4010 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let [start, sweep] = floats::<2>(r, 4)?; + let rect = *rects(r, 12, 1, compressed)?.first()?; + self.painter().fill(cv, &arc_ops(rect, start, sweep, true), &fill); + Some(()) + } + 0x4011 | 0x4012 => { + let pen = self.pen(id)?; + let [start, sweep] = floats::<2>(r, 0)?; + let rect = *rects(r, 8, 1, compressed)?.first()?; + self.painter().stroke(cv, &arc_ops(rect, start, sweep, typ == 0x4011), pen); + Some(()) + } + _ => None, + } + } + + /// Clear, region fill, image and text records. + fn record_image_text_region(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + let compressed = flags & 0x4000 != 0; + let id = usize::from(flags & 0xFF); + match typ { // Clear: the whole picture in one colour, whatever the clip (a transparent clear // leaves nothing to draw). 0x4009 => { @@ -419,6 +461,14 @@ impl Plus { } 0x401C => self.draw_string(cv, flags, r), 0x4036 => self.draw_driver_string(cv, flags, r), + _ => None, + } + } + + /// Save / restore, transform and clip records. + fn record_state(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + let id = usize::from(flags & 0xFF); + match typ { 0x4025 | 0x4028 => r.u32(0).map(|i| self.push(i)), 0x4027 => r.u32(24).map(|i| self.push(i)), 0x4026 | 0x4029 => r.u32(0).map(|i| self.pop(i)), @@ -470,20 +520,6 @@ impl Plus { } Some(()) } - 0x4010 => { - let fill = self.brush_of(flags, r.u32(0)?)?; - let [start, sweep] = floats::<2>(r, 4)?; - let rect = *rects(r, 12, 1, compressed)?.first()?; - self.painter().fill(cv, &arc_ops(rect, start, sweep, true), &fill); - Some(()) - } - 0x4011 | 0x4012 => { - let pen = self.pen(id)?; - let [start, sweep] = floats::<2>(r, 0)?; - let rect = *rects(r, 8, 1, compressed)?.first()?; - self.painter().stroke(cv, &arc_ops(rect, start, sweep, typ == 0x4011), pen); - Some(()) - } _ => None, } } diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index af6948ea..f8200e09 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -49,7 +49,7 @@ struct B { } impl B { - fn shape(&mut self, name: &str, kind: PhType, idx: u32, x: f64, y: f64, w: f64, h: f64) -> Shape { + fn shape(&mut self, name: &str, kind: PhType, idx: u32, [x, y, w, h]: [f64; 4]) -> Shape { self.next += 1; Shape { id: ShapeId(self.next), @@ -164,9 +164,9 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { let (x1, y1, w1, h1) = r(60.0, 500.0, 216.0, 28.0); let (x2, _, w2, _) = r(318.0, 500.0, 324.0, 28.0); let (x3, _, w3, _) = r(684.0, 500.0, 216.0, 28.0); - let mut d = b.shape("Date Placeholder", PhType::Date, 10, x1, y1, w1, h1); - let mut f = b.shape("Footer Placeholder", PhType::Footer, 11, x2, y1, w2, h1); - let mut n = b.shape("Slide Number Placeholder", PhType::SlideNum, 12, x3, y1, w3, h1); + let mut d = b.shape("Date Placeholder", PhType::Date, 10, [x1, y1, w1, h1]); + let mut f = b.shape("Footer Placeholder", PhType::Footer, 11, [x2, y1, w2, h1]); + let mut n = b.shape("Slide Number Placeholder", PhType::SlideNum, 12, [x3, y1, w3, h1]); let small = |align| { let mut ls = ListStyle::default(); ls.set( @@ -202,13 +202,13 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { }; let title_box = |b: &mut B| { let (x, y, ww, hh) = r(60.0, 29.0, 840.0, 104.0); - let mut s = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh); + let mut s = b.shape("Title 1", PhType::Title, 0, [x, y, ww, hh]); s.text = Some(body_with(Some(Anchor::Middle), Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }))); s }; let body_box = |b: &mut B, name: &str, idx: u32, x: f64, y: f64, ww: f64, hh: f64| { let (x, y, ww, hh) = r(x, y, ww, hh); - let mut s = b.shape(name, PhType::Body, idx, x, y, ww, hh); + let mut s = b.shape(name, PhType::Body, idx, [x, y, ww, hh]); s.text = Some(body_with(Some(Anchor::Top), Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }))); s }; @@ -251,7 +251,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { // Title Slide { let (x, y, ww, hh) = r(120.0, 88.0, 720.0, 188.0); - let mut t = b.shape("Title 1", PhType::CtrTitle, 0, x, y, ww, hh); + let mut t = b.shape("Title 1", PhType::CtrTitle, 0, [x, y, ww, hh]); let mut ls = ListStyle::default(); ls.set( 0, @@ -270,7 +270,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { paragraphs: vec![], }); let (x, y, ww, hh) = r(120.0, 284.0, 720.0, 130.0); - let mut st = b.shape("Subtitle 2", PhType::SubTitle, 1, x, y, ww, hh); + let mut st = b.shape("Subtitle 2", PhType::SubTitle, 1, [x, y, ww, hh]); let mut ls = ListStyle::default(); ls.set( 0, @@ -309,7 +309,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { // Section Header { let (x, y, ww, hh) = r(65.0, 135.0, 830.0, 225.0); - let mut t = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh); + let mut t = b.shape("Title 1", PhType::Title, 0, [x, y, ww, hh]); let mut ls = ListStyle::default(); ls.set(0, LevelStyle { run: RunProps { size: Some(60.0), ..Default::default() }, ..Default::default() }); t.text = Some(TextBody { body: BodyProps { anchor: Some(Anchor::Bottom), ..Default::default() }, list_style: ls, paragraphs: vec![] }); @@ -347,7 +347,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { let t = title_box(&mut b); let head = |b: &mut B, name: &str, idx: u32, x: f64| { let (x, y, ww, hh) = r(x, 133.0, 412.0, 62.0); - let mut s = b.shape(name, PhType::Body, idx, x, y, ww, hh); + let mut s = b.shape(name, PhType::Body, idx, [x, y, ww, hh]); let mut ls = ListStyle::default(); ls.set( 0, @@ -381,7 +381,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { // Content with Caption { let (x, y, ww, hh) = r(60.0, 36.0, 322.0, 126.0); - let mut t = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh); + let mut t = b.shape("Title 1", PhType::Title, 0, [x, y, ww, hh]); let mut ls = ListStyle::default(); ls.set(0, LevelStyle { run: RunProps { size: Some(32.0), ..Default::default() }, ..Default::default() }); t.text = Some(TextBody { body: BodyProps { anchor: Some(Anchor::Bottom), ..Default::default() }, list_style: ls, paragraphs: vec![] }); @@ -405,12 +405,12 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { // Picture with Caption { let (x, y, ww, hh) = r(60.0, 36.0, 322.0, 126.0); - let mut t = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh); + let mut t = b.shape("Title 1", PhType::Title, 0, [x, y, ww, hh]); let mut ls = ListStyle::default(); ls.set(0, LevelStyle { run: RunProps { size: Some(32.0), ..Default::default() }, ..Default::default() }); t.text = Some(TextBody { body: BodyProps { anchor: Some(Anchor::Bottom), ..Default::default() }, list_style: ls, paragraphs: vec![] }); let (x, y, ww, hh) = r(412.0, 78.0, 488.0, 384.0); - let p = b.shape("Picture Placeholder 2", PhType::Picture, 1, x, y, ww, hh); + let p = b.shape("Picture Placeholder 2", PhType::Picture, 1, [x, y, ww, hh]); let mut cap = body_box(&mut b, "Text Placeholder 3", 2, 60.0, 162.0, 322.0, 300.0); let mut ls = ListStyle::default(); ls.set( @@ -444,7 +444,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { // Vertical Title and Text { let (x, y, ww, hh) = r(687.0, 29.0, 213.0, 458.0); - let mut t = b.shape("Vertical Title 1", PhType::Title, 0, x, y, ww, hh); + let mut t = b.shape("Vertical Title 1", PhType::Title, 0, [x, y, ww, hh]); t.text = Some(TextBody { body: BodyProps { vert: Some(TextDir::EaVertical), ..Default::default() }, ..Default::default() }); if let Some(p) = t.ph.as_mut() { p.vertical = true; diff --git a/crates/opus/src/celt.rs b/crates/opus/src/celt.rs index 0cdb61cb..fdd3f694 100644 --- a/crates/opus/src/celt.rs +++ b/crates/opus/src/celt.rs @@ -59,6 +59,80 @@ pub struct CeltDecoder { pcm_scratch: Vec, } +/// Detects the silence flag, advancing the decoder to the end of the frame when set. Returns the flag and the new bit position. +fn read_silence(dec: &mut RangeDecoder, total_bits_raw: i32) -> (bool, i32) { + let tell = dec.tell(); + let silence = tell >= total_bits_raw || (tell == 1 && dec.bit_logp(15)); + if silence { + // Pretend we've read all the remaining bits. + dec.skip_to_end(total_bits_raw); + return (true, total_bits_raw); + } + (false, tell) +} + +struct PostfilterParams { + gain: f32, + pitch: i32, + tapset: usize, + tell: i32, +} + +/// Reads the optional post-filter header (pitch, gain, tapset) at the start of a frame. +fn read_postfilter_params(dec: &mut RangeDecoder, start: usize, tell: i32, total_bits_raw: i32) -> PostfilterParams { + let mut p = PostfilterParams { gain: 0.0, pitch: 0, tapset: 0, tell }; + if start == 0 && tell + 16 <= total_bits_raw { + if dec.bit_logp(1) { + let octave = dec.uint(6); + p.pitch = ((16 << octave) + dec.bits(4 + octave)) as i32 - 1; + let qg = dec.bits(3); + if dec.tell() + 2 <= total_bits_raw { + p.tapset = dec.icdf(&TAPSET_ICDF, 2); + } + p.gain = 0.09375 * (qg + 1) as f32; + } + p.tell = dec.tell(); + } + p +} + +/// Decodes per-band dynamic allocation boosts. Returns the offsets, the fractional bit position and the remaining bit budget. +fn decode_dynalloc( + dec: &mut RangeDecoder, + start: usize, + end: usize, + c: usize, + lm: usize, + cap: &[i32], + total_bits_raw: i32, +) -> ([i32; NB_EBANDS], i32, i32) { + let mut offsets = [0i32; NB_EBANDS]; + let mut dynalloc_logp = 6; + let mut total_bits = total_bits_raw << BITRES; + let mut tellf = dec.tell_frac() as i32; + for i in start..end { + let width = (c as i32 * (EBANDS[i + 1] - EBANDS[i])) << lm; + let quanta = (width << BITRES).min((6 << BITRES).max(width)); + let mut loop_logp = dynalloc_logp; + let mut boost = 0; + while tellf + (loop_logp << BITRES) < total_bits && boost < cap[i] { + let flag = dec.bit_logp(loop_logp as u32); + tellf = dec.tell_frac() as i32; + if !flag { + break; + } + boost += quanta; + total_bits -= quanta; + loop_logp = 1; + } + offsets[i] = boost; + if boost > 0 { + dynalloc_logp = 2.max(dynalloc_logp - 1); + } + } + (offsets, tellf, total_bits) +} + impl CeltDecoder { /// `sample_rate` is the output rate (48000, 24000, 16000, 12000 or 8000). pub fn new(sample_rate: u32, channels: usize) -> CeltDecoder { @@ -176,34 +250,10 @@ impl CeltDecoder { } } let total_bits_raw = len as i32 * 8; - let mut tell = dec.tell(); - let silence = if tell >= total_bits_raw { - true - } else if tell == 1 { - dec.bit_logp(15) - } else { - false - }; - if silence { - // Pretend we've read all the remaining bits. - dec.skip_to_end(total_bits_raw); - tell = total_bits_raw; - } - let mut postfilter_gain = 0.0f32; - let mut postfilter_pitch = 0i32; - let mut postfilter_tapset = 0usize; - if start == 0 && tell + 16 <= total_bits_raw { - if dec.bit_logp(1) { - let octave = dec.uint(6); - postfilter_pitch = ((16 << octave) + dec.bits(4 + octave)) as i32 - 1; - let qg = dec.bits(3); - if dec.tell() + 2 <= total_bits_raw { - postfilter_tapset = dec.icdf(&TAPSET_ICDF, 2); - } - postfilter_gain = 0.09375 * (qg + 1) as f32; - } - tell = dec.tell(); - } + let (silence, mut tell) = read_silence(dec, total_bits_raw); + let pf = read_postfilter_params(dec, start, tell, total_bits_raw); + tell = pf.tell; + let (postfilter_gain, postfilter_pitch, postfilter_tapset) = (pf.gain, pf.pitch, pf.tapset); let is_transient = if lm > 0 && tell + 3 <= total_bits_raw { let t = dec.bit_logp(3); tell = dec.tell(); @@ -222,30 +272,7 @@ impl CeltDecoder { spread = dec.icdf(&SPREAD_ICDF, 5); } let cap = m.init_caps(lm, c); - let mut offsets = [0i32; NB_EBANDS]; - let mut dynalloc_logp = 6; - let mut total_bits = total_bits_raw << BITRES; - let mut tellf = dec.tell_frac() as i32; - for i in start..end { - let width = (c as i32 * (EBANDS[i + 1] - EBANDS[i])) << lm; - let quanta = (width << BITRES).min((6 << BITRES).max(width)); - let mut loop_logp = dynalloc_logp; - let mut boost = 0; - while tellf + (loop_logp << BITRES) < total_bits && boost < cap[i] { - let flag = dec.bit_logp(loop_logp as u32); - tellf = dec.tell_frac() as i32; - if !flag { - break; - } - boost += quanta; - total_bits -= quanta; - loop_logp = 1; - } - offsets[i] = boost; - if boost > 0 { - dynalloc_logp = 2.max(dynalloc_logp - 1); - } - } + let (offsets, tellf, total_bits) = decode_dynalloc(dec, start, end, c, lm, &cap, total_bits_raw); let alloc_trim = if tellf + (6 << BITRES) <= total_bits { dec.icdf(&TRIM_ICDF, 7) as i32 } else { 5 }; let mut bits = ((len as i32 * 8) << BITRES) - dec.tell_frac() as i32 - 1; let anti_collapse_rsv = if is_transient && lm >= 2 && bits >= ((lm as i32 + 2) << BITRES) { 1 << BITRES } else { 0 }; diff --git a/crates/opus/src/celt/bands.rs b/crates/opus/src/celt/bands.rs index f4b764d7..fa04b3c5 100644 --- a/crates/opus/src/celt/bands.rs +++ b/crates/opus/src/celt/bands.rs @@ -269,8 +269,34 @@ struct Split { qalloc: i32, } +/// Block geometry for one theta decode: `n` samples, `bb` blocks per band, `b0` initial block count. +#[derive(Clone, Copy)] +struct ThetaShape { + n: i32, + bb: usize, + b0: usize, + lm: i32, + stereo: bool, +} + +/// Mid/side gains and delta for a decoded angle; also narrows `fill` to the blocks that stay coded. +fn split_gains(itheta: i32, n: i32, bb: usize, fill: &mut u32) -> (i32, i32, i32) { + if itheta == 0 { + *fill &= (1 << bb) - 1; + (32767, 0, -16384) + } else if itheta == 16384 { + *fill &= ((1 << bb) - 1) << bb; + (0, 32767, 16384) + } else { + let imid = bitexact_cos(itheta); + let iside = bitexact_cos(16384 - itheta); + (imid, iside, frac_mul16((n - 1) << 7, bitexact_log2tan(iside, imid))) + } +} + impl BandCtx<'_, '_, '_> { - fn compute_theta(&mut self, n: i32, b: &mut i32, bb: usize, b0: usize, lm: i32, stereo: bool, fill: &mut u32) -> Split { + fn compute_theta(&mut self, shape: ThetaShape, b: &mut i32, fill: &mut u32) -> Split { + let ThetaShape { n, bb, b0, lm, stereo } = shape; let i = self.i; let pulse_cap = self.m.log_n[i] + lm * (1 << BITRES); let offset = (pulse_cap >> 1) - if stereo && n == 2 { QTHETA_OFFSET_TWOPHASE } else { QTHETA_OFFSET }; @@ -282,67 +308,66 @@ impl BandCtx<'_, '_, '_> { let mut itheta = 0i32; let mut inv = false; if qn != 1 { - if stereo && n > 2 { - let p0 = 3u32; - let x0 = (qn / 2) as u32; - let ft = p0 * (x0 + 1) + x0; - let fs = self.dec.decode(ft); - let x = if fs < (x0 + 1) * p0 { fs / p0 } else { x0 + 1 + (fs - (x0 + 1) * p0) }; - let (fl, fh) = if x <= x0 { (p0 * x, p0 * (x + 1)) } else { ((x - 1 - x0) + (x0 + 1) * p0, (x - x0) + (x0 + 1) * p0) }; - self.dec.update(fl, fh, ft); - itheta = x as i32; - } else if b0 > 1 || stereo { - itheta = self.dec.uint(qn as u32 + 1) as i32; - } else { - let half = (qn >> 1) as u32; - let ft = (half + 1) * (half + 1); - let fm = self.dec.decode(ft); - let (fl, fs); - if fm < (half * (half + 1)) >> 1 { - let it = (isqrt32(8 * fm + 1) - 1) >> 1; - fs = it + 1; - fl = it * (it + 1) >> 1; - itheta = it as i32; - } else { - let q = qn as u32; - let it = (2 * (q + 1) - isqrt32(8 * (ft - fm - 1) + 1)) >> 1; - fs = q + 1 - it; - fl = ft - ((q + 1 - it) * (q + 2 - it) >> 1); - itheta = it as i32; - } - self.dec.update(fl, fl + fs, ft); - } - itheta = ((itheta as u32 * 16384) / qn as u32) as i32; + itheta = self.decode_itheta(qn, n, b0, stereo); } else if stereo { - if *b > 2 << BITRES && self.remaining_bits > 2 << BITRES { - inv = self.dec.bit_logp(2); - } - if self.disable_inv { - inv = false; - } - itheta = 0; + inv = self.decode_inversion(*b); } let qalloc = self.dec.tell_frac() as i32 - tell; *b -= qalloc; - let (imid, iside, delta); - if itheta == 0 { - imid = 32767; - iside = 0; - *fill &= (1 << bb) - 1; - delta = -16384; - } else if itheta == 16384 { - imid = 0; - iside = 32767; - *fill &= ((1 << bb) - 1) << bb; - delta = 16384; - } else { - imid = bitexact_cos(itheta); - iside = bitexact_cos(16384 - itheta); - delta = frac_mul16((n - 1) << 7, bitexact_log2tan(iside, imid)); - } + let (imid, iside, delta) = split_gains(itheta, n, bb, fill); Split { inv, imid, iside, delta, itheta, qalloc } } + /// Decodes the quantised stereo/band angle for `qn > 1` and scales it to the 0..=16384 range. + fn decode_itheta(&mut self, qn: i32, n: i32, b0: usize, stereo: bool) -> i32 { + let itheta = if stereo && n > 2 { + self.decode_itheta_stereo_triangle(qn) + } else if b0 > 1 || stereo { + self.dec.uint(qn as u32 + 1) as i32 + } else { + self.decode_itheta_triangle(qn) + }; + ((itheta as u32 * 16384) / qn as u32) as i32 + } + + fn decode_itheta_stereo_triangle(&mut self, qn: i32) -> i32 { + let p0 = 3u32; + let x0 = (qn / 2) as u32; + let ft = p0 * (x0 + 1) + x0; + let fs = self.dec.decode(ft); + let x = if fs < (x0 + 1) * p0 { fs / p0 } else { x0 + 1 + (fs - (x0 + 1) * p0) }; + let (fl, fh) = if x <= x0 { (p0 * x, p0 * (x + 1)) } else { ((x - 1 - x0) + (x0 + 1) * p0, (x - x0) + (x0 + 1) * p0) }; + self.dec.update(fl, fh, ft); + x as i32 + } + + fn decode_itheta_triangle(&mut self, qn: i32) -> i32 { + let half = (qn >> 1) as u32; + let ft = (half + 1) * (half + 1); + let fm = self.dec.decode(ft); + let (fl, fs, itheta); + if fm < (half * (half + 1)) >> 1 { + let it = (isqrt32(8 * fm + 1) - 1) >> 1; + fs = it + 1; + fl = it * (it + 1) >> 1; + itheta = it as i32; + } else { + let q = qn as u32; + let it = (2 * (q + 1) - isqrt32(8 * (ft - fm - 1) + 1)) >> 1; + fs = q + 1 - it; + fl = ft - ((q + 1 - it) * (q + 2 - it) >> 1); + itheta = it as i32; + } + self.dec.update(fl, fl + fs, ft); + itheta + } + + /// Reads the stereo inversion flag when there are enough bits left (never when inversion is disabled). + fn decode_inversion(&mut self, b: i32) -> bool { + let inv = b > 2 << BITRES && self.remaining_bits > 2 << BITRES && self.dec.bit_logp(2); + inv && !self.disable_inv + } + fn quant_band_n1(&mut self, x: &mut [f32], y: Option<&mut [f32]>, lowband_out: Option<&mut [f32]>) -> u32 { let dec_sign = |ctx: &mut Self| -> f32 { let mut sign = 0; @@ -386,7 +411,7 @@ impl BandCtx<'_, '_, '_> { fill = (fill & 1) | (fill << 1); } bb = (bb + 1) >> 1; - let sp = self.compute_theta(n as i32, &mut b, bb, b0, lm, false, &mut fill); + let sp = self.compute_theta(ThetaShape { n: n as i32, bb, b0, lm, stereo: false }, &mut b, &mut fill); let mid = sp.imid as f32 / 32768.0; let side = sp.iside as f32 / 32768.0; let mut delta = sp.delta; @@ -574,7 +599,7 @@ impl BandCtx<'_, '_, '_> { if n == 1 { return self.quant_band_n1(x, Some(y), lowband_out); } - let sp = self.compute_theta(n as i32, &mut b, bb, bb, lm, true, &mut fill); + let sp = self.compute_theta(ThetaShape { n: n as i32, bb, b0: bb, lm, stereo: true }, &mut b, &mut fill); let mid = sp.imid as f32 / 32768.0; let side = sp.iside as f32 / 32768.0; let mut cm; diff --git a/crates/opus/src/decoder.rs b/crates/opus/src/decoder.rs index a9cdee29..cc3b01a0 100644 --- a/crates/opus/src/decoder.rs +++ b/crates/opus/src/decoder.rs @@ -202,19 +202,10 @@ impl StreamDecoder { Some(m) => m, }; if audiosize > f20 { - let mut done = 0; - while done < audiosize { - let r = self.decode_frame(None, &mut pcm[done * ch..], (audiosize - done).min(f20), false)?; - done += r; - } + self.conceal_long(pcm, audiosize, f20)?; return Ok(frame_size); - } else if audiosize < f20 { - if audiosize > f10 { - audiosize = f10; - } else if mode != Mode::SilkOnly && audiosize > f5 && audiosize < f10 { - audiosize = f5; - } } + audiosize = shorten_concealment(audiosize, mode, fs); } } let data_len = data.map_or(0, <[_]>::len); @@ -248,76 +239,21 @@ impl StreamDecoder { // SILK. let mut pcm_silk: Vec = Vec::new(); if mode != Mode::CeltOnly { - pcm_silk.resize(frame_size.max(f10) * ch, 0.0); - if self.prev_mode == Some(Mode::CeltOnly) { - self.silk.reset(); - } - let payload_ms = (1000 * audiosize / fs).max(10); - let internal_rate = if mode == Mode::SilkOnly { - match bandwidth { - Some(Bandwidth::Narrowband) => 8000, - Some(Bandwidth::Mediumband) => 12000, - _ => 16000, - } - } else { - 16000 - }; - if data.is_some() { - self.silk.set_stream(self.stream_channels, internal_rate); - } - let lost = if data.is_none() { - LostFlag::Lost - } else if fec { - LostFlag::Lbrr - } else { - LostFlag::Normal - }; - let mut decoded = 0; - while decoded < frame_size { - let first = decoded == 0; - let n = match self.silk.decode(dec.as_deref_mut(), lost, first, payload_ms, &mut pcm_silk[decoded * ch..]) { - Ok(n) => n, - Err(e) => { - if lost != LostFlag::Normal { - let n = frame_size - decoded; - pcm_silk[decoded * ch..(decoded + n) * ch].fill(0.0); - n - } else { - return Err(e); - } - } - }; - if n == 0 { - break; - } - decoded += n; - } + pcm_silk = self.decode_silk_layer( + dec.as_deref_mut(), + SilkLayer { mode, bandwidth, lost: lost_flag(data.is_some(), fec), frame_size, audiosize, f10 }, + )?; } let mut start_band = 0; - let mut redundancy = false; - let mut celt_to_silk = false; - let mut redundancy_bytes = 0usize; - let mut len = data_len; + let mut redundancy = Redundancy::none(data_len); if !fec && mode != Mode::CeltOnly && let Some(d) = dec.as_deref_mut() - && d.tell() + 17 + 20 * (self.mode == Some(Mode::Hybrid)) as i32 <= 8 * len as i32 { - redundancy = if mode == Mode::Hybrid { d.bit_logp(12) } else { true }; - if redundancy { - celt_to_silk = d.bit_logp(1); - redundancy_bytes = if mode == Mode::Hybrid { d.uint(256) as usize + 2 } else { len - ((d.tell() as usize + 7) >> 3) }; - if redundancy_bytes > len || (len - redundancy_bytes) * 8 < d.tell() as usize { - len = 0; - redundancy_bytes = 0; - redundancy = false; - } else { - len -= redundancy_bytes; - } - d.shrink_storage(redundancy_bytes); - } + redundancy = Redundancy::parse(d, mode, self.mode == Some(Mode::Hybrid), data_len); } + let Redundancy { present: redundancy, celt_to_silk, bytes: redundancy_bytes, len } = redundancy; if mode != Mode::CeltOnly { start_band = 17; } @@ -375,15 +311,11 @@ impl StreamDecoder { smooth_fade(&tail, &redundant_audio[ch * f2_5..], &mut pcm[o..], f2_5, ch, window, self.rate); } if redundancy && celt_to_silk { - pcm[..f2_5 * ch].copy_from_slice(&redundant_audio[..f2_5 * ch]); - let cur: Vec = pcm[ch * f2_5..ch * 2 * f2_5].to_vec(); - smooth_fade(&redundant_audio[ch * f2_5..], &cur, &mut pcm[ch * f2_5..], f2_5, ch, window, self.rate); + self.fade_in_from(pcm, &redundant_audio, f2_5); } if transition { if audiosize >= f5 { - pcm[..f2_5 * ch].copy_from_slice(&pcm_transition[..f2_5 * ch]); - let cur: Vec = pcm[ch * f2_5..ch * 2 * f2_5].to_vec(); - smooth_fade(&pcm_transition[ch * f2_5..], &cur, &mut pcm[ch * f2_5..], f2_5, ch, window, self.rate); + self.fade_in_from(pcm, &pcm_transition, f2_5); } else { let cur: Vec = pcm[..ch * f2_5].to_vec(); smooth_fade(&pcm_transition, &cur, pcm, f2_5, ch, window, self.rate); @@ -403,6 +335,135 @@ impl StreamDecoder { self.prev_redundancy = redundancy && !celt_to_silk; Ok(audiosize) } + + /// Conceals a lost span longer than 20 ms by decoding it in 20 ms pieces. + fn conceal_long(&mut self, pcm: &mut [f32], audiosize: usize, f20: usize) -> Result<()> { + let mut done = 0; + while done < audiosize { + done += self.decode_frame(None, &mut pcm[done * self.channels..], (audiosize - done).min(f20), false)?; + } + Ok(()) + } + + /// Replaces the first `f2_5` samples of `pcm` with `src` and cross-fades the following `f2_5` samples from `src` into `pcm`. + fn fade_in_from(&self, pcm: &mut [f32], src: &[f32], f2_5: usize) { + let ch = self.channels; + let window = &crate::celt::mode().window; + pcm[..f2_5 * ch].copy_from_slice(&src[..f2_5 * ch]); + let cur: Vec = pcm[ch * f2_5..ch * 2 * f2_5].to_vec(); + smooth_fade(&src[ch * f2_5..], &cur, &mut pcm[ch * f2_5..], f2_5, ch, window, self.rate); + } + + /// Runs the SILK layer over the frame, zero-filling spans it cannot decode when the packet was lost. + fn decode_silk_layer(&mut self, mut dec: Option<&mut RangeDecoder>, l: SilkLayer) -> Result> { + let ch = self.channels; + let mut pcm_silk = vec![0.0; l.frame_size.max(l.f10) * ch]; + if self.prev_mode == Some(Mode::CeltOnly) { + self.silk.reset(); + } + let payload_ms = (1000 * l.audiosize / self.rate as usize).max(10); + if l.lost != LostFlag::Lost { + self.silk.set_stream(self.stream_channels, silk_internal_rate(l.mode, l.bandwidth)); + } + let mut decoded = 0; + while decoded < l.frame_size { + let first = decoded == 0; + let out = &mut pcm_silk[decoded * ch..]; + let n = match self.silk.decode(dec.as_deref_mut(), l.lost, first, payload_ms, out) { + Ok(n) => n, + Err(_) if l.lost != LostFlag::Normal => { + let n = l.frame_size - decoded; + pcm_silk[decoded * ch..(decoded + n) * ch].fill(0.0); + n + } + Err(e) => return Err(e), + }; + if n == 0 { + break; + } + decoded += n; + } + Ok(pcm_silk) + } +} + +/// Inputs for one SILK-layer decode. +struct SilkLayer { + mode: Mode, + bandwidth: Option, + lost: LostFlag, + frame_size: usize, + audiosize: usize, + f10: usize, +} + +fn lost_flag(has_data: bool, fec: bool) -> LostFlag { + if !has_data { + LostFlag::Lost + } else if fec { + LostFlag::Lbrr + } else { + LostFlag::Normal + } +} + +fn silk_internal_rate(mode: Mode, bandwidth: Option) -> u32 { + match (mode, bandwidth) { + (Mode::SilkOnly, Some(Bandwidth::Narrowband)) => 8000, + (Mode::SilkOnly, Some(Bandwidth::Mediumband)) => 12000, + _ => 16000, + } +} + +/// Picks the shorter concealment length for spans of at most 20 ms (10 ms, or 5 ms outside SILK-only mode). +fn shorten_concealment(audiosize: usize, mode: Mode, rate: usize) -> usize { + let f20 = rate / 50; + let f10 = f20 >> 1; + let f5 = f10 >> 1; + if audiosize >= f20 { + audiosize + } else if audiosize > f10 { + f10 + } else if mode != Mode::SilkOnly && audiosize > f5 && audiosize < f10 { + f5 + } else { + audiosize + } +} + +/// In-band redundancy header of a packet: whether a redundant CELT frame follows, its direction and size. +struct Redundancy { + present: bool, + celt_to_silk: bool, + bytes: usize, + /// Payload length left for the main frame. + len: usize, +} + +impl Redundancy { + fn none(len: usize) -> Self { + Self { present: false, celt_to_silk: false, bytes: 0, len } + } + + fn parse(d: &mut RangeDecoder, mode: Mode, prev_hybrid: bool, len: usize) -> Self { + let mut r = Self::none(len); + if d.tell() + 17 + 20 * prev_hybrid as i32 > 8 * len as i32 { + return r; + } + r.present = if mode == Mode::Hybrid { d.bit_logp(12) } else { true }; + if !r.present { + return r; + } + r.celt_to_silk = d.bit_logp(1); + r.bytes = if mode == Mode::Hybrid { d.uint(256) as usize + 2 } else { len - ((d.tell() as usize + 7) >> 3) }; + if r.bytes > len || (len - r.bytes) * 8 < d.tell() as usize { + r = Self { celt_to_silk: r.celt_to_silk, ..Self::none(0) }; + } else { + r.len -= r.bytes; + } + d.shrink_storage(r.bytes); + r + } } fn smooth_fade(in1: &[f32], in2: &[f32], out: &mut [f32], overlap: usize, ch: usize, window: &[f32; OVERLAP], rate: u32) { diff --git a/crates/opus/src/silk.rs b/crates/opus/src/silk.rs index e6e096e6..6f739c3a 100644 --- a/crates/opus/src/silk.rs +++ b/crates/opus/src/silk.rs @@ -716,19 +716,7 @@ impl SilkDecoder { } let stereo_to_mono = nci == 1 && self.channels_internal_prev == 2 && self.internal_rate == 1000 * self.ch[0].fs_khz as u32; if self.ch[0].n_frames_decoded == 0 { - for c in self.ch.iter_mut().take(nci) { - let (nfpp, nb) = match payload_ms { - 10 => (1, 2), - 20 => (1, 4), - 40 => (2, 4), - 60 => (3, 4), - _ => return Err(Error::InvalidPacket("bad SILK frame size")), - }; - c.n_frames_per_packet = nfpp; - c.nb_subfr = nb; - let fs_khz = ((self.internal_rate >> 10) + 1) as usize; - c.set_fs(fs_khz, self.api_rate); - } + self.configure_channels(nci, payload_ms)?; } if self.api_channels == 2 && nci == 2 && self.channels_internal_prev == 1 { self.stereo.pred_prev_q13 = [0; 2]; @@ -739,97 +727,20 @@ impl SilkDecoder { if lost != LostFlag::Lost && self.ch[0].n_frames_decoded == 0 { let d = dec.as_deref_mut().ok_or(Error::InvalidPacket("missing SILK data"))?; - for c in self.ch.iter_mut().take(nci) { - for i in 0..c.n_frames_per_packet { - c.vad_flags[i] = d.bit_logp(1); - } - c.lbrr_flag = d.bit_logp(1); - } - for c in self.ch.iter_mut().take(nci) { - c.lbrr_flags = [false; 3]; - if c.lbrr_flag { - if c.n_frames_per_packet == 1 { - c.lbrr_flags[0] = true; - } else { - let sym = d.icdf(LBRR_FLAGS[c.n_frames_per_packet - 2], 8) + 1; - for i in 0..c.n_frames_per_packet { - c.lbrr_flags[i] = (sym >> i) & 1 != 0; - } - } - } - } + self.read_packet_flags(d, nci); if lost == LostFlag::Normal { - // Skip LBRR data. - for i in 0..self.ch[0].n_frames_per_packet { - for n in 0..nci { - if self.ch[n].lbrr_flags[i] { - if nci == 2 && n == 0 { - stereo_decode_pred(d); - if !self.ch[1].lbrr_flags[i] { - d.icdf(&MID_ONLY, 8); - } - } - let cond = if i > 0 && self.ch[n].lbrr_flags[i - 1] { Cond::Conditionally } else { Cond::Independently }; - let c = &mut self.ch[n]; - c.decode_indices(d, i, true, cond); - let mut pulses = [0i32; MAX_FRAME + 16]; - decode_pulses(d, &mut pulses, c.indices.signal_type, c.indices.quant_offset_type, c.frame_length); - } - } - } + self.skip_lbrr_data(d, nci); } } - // Stereo prediction. if nci == 2 { - let fi = self.ch[0].n_frames_decoded; - let coded = lost == LostFlag::Normal || (lost == LostFlag::Lbrr && self.ch[0].lbrr_flags[fi]); - match dec.as_deref_mut() { - Some(d) if coded => { - self.ms_pred = stereo_decode_pred(d); - let side_inactive = - (lost == LostFlag::Normal && !self.ch[1].vad_flags[fi]) || (lost == LostFlag::Lbrr && !self.ch[1].lbrr_flags[fi]); - self.decode_only_middle = if side_inactive { d.icdf(&MID_ONLY, 8) == 1 } else { false }; - } - _ => { - self.ms_pred = self.stereo.pred_prev_q13; - } - } + self.decode_stereo_prediction(dec.as_deref_mut(), lost); } if nci == 2 && !self.decode_only_middle && self.prev_decode_only_middle { - let c = &mut self.ch[1]; - c.out_buf = [0; OUT_BUF]; - c.s_lpc_q14 = [0; MAX_LPC_ORDER]; - c.lag_prev = 100; - c.last_gain_index = 10; - c.prev_signal_type = 0; - c.first_frame_after_reset = true; + self.reset_side_channel(); } let l = self.ch[0].frame_length; let mut buf = [[0i16; MAX_FRAME + 2]; 2]; - let has_side = if lost == LostFlag::Normal { - !self.decode_only_middle - } else { - !self.prev_decode_only_middle || (nci == 2 && lost == LostFlag::Lbrr && self.ch[1].lbrr_flags[self.ch[1].n_frames_decoded]) - }; - for n in 0..nci { - if n == 0 || has_side { - let frame_index = self.ch[0].n_frames_decoded as isize - n as isize; - let cond = if frame_index <= 0 { - Cond::Independently - } else if lost == LostFlag::Lbrr { - if self.ch[n].lbrr_flags[frame_index as usize - 1] { Cond::Conditionally } else { Cond::Independently } - } else if n > 0 && self.prev_decode_only_middle { - Cond::IndependentlyNoLtpScaling - } else { - Cond::Conditionally - }; - let c = &mut self.ch[n]; - c.decode_frame(dec.as_deref_mut(), &mut buf[n][2..], lost, cond); - } else { - buf[n][2..2 + l].fill(0); - } - self.ch[n].n_frames_decoded += 1; - } + self.decode_channel_frames(dec, lost, nci, &mut buf); if self.api_channels == 2 && nci == 2 { let (a, b) = buf.split_at_mut(1); stereo_ms_to_lr(&mut self.stereo, &mut a[0], &mut b[0], self.ms_pred, self.ch[0].fs_khz, l); @@ -837,6 +748,142 @@ impl SilkDecoder { buf[0][..2].copy_from_slice(&self.stereo.s_mid); self.stereo.s_mid.copy_from_slice(&buf[0][l..l + 2]); } + let n_out = self.resample_to_output(&buf, nci, stereo_to_mono, out); + if lost == LostFlag::Lost { + for c in self.ch.iter_mut().take(nci) { + c.last_gain_index = 10; + } + } else { + self.prev_decode_only_middle = self.decode_only_middle; + } + Ok(n_out) + } + + /// Applies the packet's frame layout and internal sampling rate to the first `nci` channels. + fn configure_channels(&mut self, nci: usize, payload_ms: usize) -> Result<()> { + let (nfpp, nb) = match payload_ms { + 10 => (1, 2), + 20 => (1, 4), + 40 => (2, 4), + 60 => (3, 4), + _ => return Err(Error::InvalidPacket("bad SILK frame size")), + }; + let fs_khz = ((self.internal_rate >> 10) + 1) as usize; + for c in self.ch.iter_mut().take(nci) { + c.n_frames_per_packet = nfpp; + c.nb_subfr = nb; + c.set_fs(fs_khz, self.api_rate); + } + Ok(()) + } + + /// Reads the per-frame VAD flags and LBRR flags at the start of a packet. + fn read_packet_flags(&mut self, d: &mut RangeDecoder, nci: usize) { + for c in self.ch.iter_mut().take(nci) { + for i in 0..c.n_frames_per_packet { + c.vad_flags[i] = d.bit_logp(1); + } + c.lbrr_flag = d.bit_logp(1); + } + for c in self.ch.iter_mut().take(nci) { + c.lbrr_flags = [false; 3]; + if !c.lbrr_flag { + continue; + } + if c.n_frames_per_packet == 1 { + c.lbrr_flags[0] = true; + } else { + let sym = d.icdf(LBRR_FLAGS[c.n_frames_per_packet - 2], 8) + 1; + for i in 0..c.n_frames_per_packet { + c.lbrr_flags[i] = (sym >> i) & 1 != 0; + } + } + } + } + + /// Consumes (without using) the in-band LBRR frames of a normally decoded packet. + fn skip_lbrr_data(&mut self, d: &mut RangeDecoder, nci: usize) { + for i in 0..self.ch[0].n_frames_per_packet { + for n in 0..nci { + if !self.ch[n].lbrr_flags[i] { + continue; + } + if nci == 2 && n == 0 { + stereo_decode_pred(d); + if !self.ch[1].lbrr_flags[i] { + d.icdf(&MID_ONLY, 8); + } + } + let cond = if i > 0 && self.ch[n].lbrr_flags[i - 1] { Cond::Conditionally } else { Cond::Independently }; + let c = &mut self.ch[n]; + c.decode_indices(d, i, true, cond); + let mut pulses = [0i32; MAX_FRAME + 16]; + decode_pulses(d, &mut pulses, c.indices.signal_type, c.indices.quant_offset_type, c.frame_length); + } + } + } + + /// Reads the mid/side prediction weights, or reuses the previous ones when this frame carries none. + fn decode_stereo_prediction(&mut self, dec: Option<&mut RangeDecoder>, lost: LostFlag) { + let fi = self.ch[0].n_frames_decoded; + let coded = lost == LostFlag::Normal || (lost == LostFlag::Lbrr && self.ch[0].lbrr_flags[fi]); + match dec { + Some(d) if coded => { + self.ms_pred = stereo_decode_pred(d); + let side_inactive = (lost == LostFlag::Normal && !self.ch[1].vad_flags[fi]) || (lost == LostFlag::Lbrr && !self.ch[1].lbrr_flags[fi]); + self.decode_only_middle = side_inactive && d.icdf(&MID_ONLY, 8) == 1; + } + _ => self.ms_pred = self.stereo.pred_prev_q13, + } + } + + /// Resets the side channel's decoder state when it resumes after a mid-only stretch. + fn reset_side_channel(&mut self) { + let c = &mut self.ch[1]; + c.out_buf = [0; OUT_BUF]; + c.s_lpc_q14 = [0; MAX_LPC_ORDER]; + c.lag_prev = 100; + c.last_gain_index = 10; + c.prev_signal_type = 0; + c.first_frame_after_reset = true; + } + + /// Frame-coding condition for channel `n` of the frame currently being decoded. + fn frame_condition(&self, n: usize, lost: LostFlag) -> Cond { + let frame_index = self.ch[0].n_frames_decoded as isize - n as isize; + if frame_index <= 0 { + Cond::Independently + } else if lost == LostFlag::Lbrr { + if self.ch[n].lbrr_flags[frame_index as usize - 1] { Cond::Conditionally } else { Cond::Independently } + } else if n > 0 && self.prev_decode_only_middle { + Cond::IndependentlyNoLtpScaling + } else { + Cond::Conditionally + } + } + + /// Decodes the current frame of every active channel into `buf` (silence for an inactive side channel). + fn decode_channel_frames(&mut self, mut dec: Option<&mut RangeDecoder>, lost: LostFlag, nci: usize, buf: &mut [[i16; MAX_FRAME + 2]; 2]) { + let has_side = if lost == LostFlag::Normal { + !self.decode_only_middle + } else { + !self.prev_decode_only_middle || (nci == 2 && lost == LostFlag::Lbrr && self.ch[1].lbrr_flags[self.ch[1].n_frames_decoded]) + }; + let l = self.ch[0].frame_length; + for n in 0..nci { + if n == 0 || has_side { + let cond = self.frame_condition(n, lost); + self.ch[n].decode_frame(dec.as_deref_mut(), &mut buf[n][2..], lost, cond); + } else { + buf[n][2..2 + l].fill(0); + } + self.ch[n].n_frames_decoded += 1; + } + } + + /// Resamples the decoded channels to the API rate into interleaved `out`; returns samples per channel. + fn resample_to_output(&mut self, buf: &[[i16; MAX_FRAME + 2]; 2], nci: usize, stereo_to_mono: bool, out: &mut [f32]) -> usize { + let l = self.ch[0].frame_length; let n_out = l * self.api_rate as usize / (self.ch[0].fs_khz * 1000); let ach = self.api_channels; let mut tmp = Vec::with_capacity(n_out); @@ -860,13 +907,6 @@ impl SilkDecoder { } } } - if lost == LostFlag::Lost { - for c in self.ch.iter_mut().take(nci) { - c.last_gain_index = 10; - } - } else { - self.prev_decode_only_middle = self.decode_only_middle; - } - Ok(n_out) + n_out } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 1b80ad6a..32da17da 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -178,7 +178,7 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { let pdf = writer.document_mut(); let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0)); - let mut ex = Exporter { pres, opts, fonts: HashMap::new() }; + let mut ex = Exporter { pres, opts, fonts: HashMap::new(), backdrops: Backdrops::default() }; // Page plan: per page, the slides on it with their boxes (page points). let pages = plan_pages(pres, opts, &slides, sw, sh); // Bookmarks and slide โ†’ page lookup for internal links. @@ -391,6 +391,7 @@ struct Exporter<'a> { pres: &'a Presentation, opts: &'a PdfOptions, fonts: HashMap, + backdrops: Backdrops, } fn tf(a: Affine) -> Transform { @@ -406,8 +407,19 @@ fn grey(v: u8, opacity: f32) -> Fill { } } -/// Premultiplied RGBA (the renderer's pixels) to the straight RGBA the writer stores. +/// Premultiplied RGBA (the renderer's pixels) to the straight RGBA the writer stores. Runs of +/// pixels that are all fully transparent or fully opaque, most of a slide, are skipped in blocks. fn straight(rgba: &mut [u8]) { + let (blocks, rest) = rgba.as_chunks_mut::<64>(); + for block in blocks { + if block.as_chunks::<4>().0.iter().fold(false, |m, px| m | (px[3].wrapping_sub(1) < 254)) { + straight_px(block); + } + } + straight_px(rest); +} + +fn straight_px(rgba: &mut [u8]) { for px in rgba.as_chunks_mut::<4>().0 { let a = px[3] as u32; if a != 0 && a != 255 { @@ -418,17 +430,58 @@ fn straight(rgba: &mut [u8]) { } } -/// A backdrop raster as one PDF image, composited onto white (slides are normally opaque). Equal -/// backdrops become equal images, which the writer stores once. -fn backdrop(mut img: deckcraft_render::Image) -> Image { - for px in img.pixels.as_chunks_mut::<4>().0 { - let a = px[3] as u32; - for c in &mut px[..3] { - *c = (*c as u32 + (255 - a)).min(255) as u8; +/// A backdrop raster composited onto white (slides are normally opaque, and opaque blocks are skipped). +fn opaque(rgba: &mut [u8]) { + let (blocks, rest) = rgba.as_chunks_mut::<64>(); + for block in blocks { + if !block.as_chunks::<4>().0.iter().fold(true, |m, px| m & (px[3] == 255)) { + opaque_px(block); } + } + opaque_px(rest); +} + +fn opaque_px(rgba: &mut [u8]) { + for px in rgba.as_chunks_mut::<4>().0 { + let k = 255 - px[3]; + px[0] = px[0].saturating_add(k); + px[1] = px[1].saturating_add(k); + px[2] = px[2].saturating_add(k); px[3] = 255; } - Image::from_rgba8(img.pixels, img.width, img.height) +} + +/// Most bytes of opaque backdrop rasters kept to recognise a backdrop seen before. +const BACKDROP_CACHE_BYTES: usize = 32 << 20; + +/// Backdrops already handed to the writer, most recent first, with their pixels. Slides that +/// share a layout render the same backdrop; reusing its image skips hashing and compressing the +/// raster again, and the writer stores it once either way (#845). +#[derive(Default)] +struct Backdrops(Vec<(Vec, u32, u32, Image)>); + +impl Backdrops { + fn image(&mut self, mut img: deckcraft_render::Image) -> Image { + opaque(&mut img.pixels); + let (w, h) = (img.width, img.height); + if let Some(i) = self.0.iter().position(|(px, pw, ph, _)| (*pw, *ph) == (w, h) && *px == img.pixels) { + let hit = self.0.remove(i); + let image = hit.3.clone(); + self.0.insert(0, hit); + return image; + } + if img.pixels.len() > BACKDROP_CACHE_BYTES { + return Image::from_rgba8(img.pixels, w, h); + } + let image = Image::from_rgba8(img.pixels.clone(), w, h); + self.0.insert(0, (img.pixels, w, h, image.clone())); + let mut total = 0; + self.0.retain(|(px, ..)| { + total += px.len(); + total <= BACKDROP_CACHE_BYTES + }); + image + } } /// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters. @@ -470,7 +523,7 @@ impl Exporter<'_> { && let Some(size) = Size::from_wh(b.width() as f32, b.height() as f32) { s.push_transform(&Transform::from_translate(b.x0 as f32, b.y0 as f32)); - s.draw_image(backdrop(back), size); + s.draw_image(self.backdrops.image(back), size); // Taken, not copied: a print-resolution slide is ~16 MB. let mut rgba = front.pixels; if rgba.as_chunks::<4>().0.iter().any(|px| px[3] != 0) { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d135d9f6..dde7a9b7 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -8,7 +8,7 @@ use kurbo::Rect; use super::{ MAX_RASTER_PIXELS, PAPERS, PageLayout, PdfError, PdfOptions, Presentation, export, glyph_text_ranges, handout_boxes, master_text_pieces, - master_texts, notes_frames, notes_slide_box, paper_size, raster_scale, + master_texts, notes_frames, notes_slide_box, opaque, paper_size, raster_scale, straight, }; use deckcraft_model::text::TextBody; @@ -587,6 +587,51 @@ fn slides_share_one_backdrop_raster_and_keep_their_pixels() { let want_alpha: Vec = straight.as_chunks::<4>().0.iter().map(|px| px[3]).collect(); assert_eq!(rgb, want_rgb, "content colors are straight, not premultiplied"); assert_eq!(alpha, Some(want_alpha), "content alpha keeps the shape's coverage"); + + // A backdrop that comes back after another one is still stored once, and every page draws its + // own slide's backdrop. + let mut p = deck(4); + for (i, slide) in p.slides.iter_mut().enumerate() { + let s = Arc::make_mut(slide); + s.shapes.clear(); + let c = if i % 2 == 0 { deckcraft_model::Rgba::rgb(20, 90, 160) } else { deckcraft_model::Rgba::rgb(230, 230, 210) }; + s.background = Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(c)) }); + } + let stored = images(&p); + assert_eq!(stored.iter().map(|(pages, ..)| pages.clone()).collect::>(), [vec![0, 2], vec![1, 3]]); + for (pages, _, rgb, alpha, _) in &stored { + let expect = deckcraft_render::render_slide(&p, pages[0], &RenderOpts { scale: scale(&p), threads: 4, ..Default::default() }); + let want: Vec = expect.pixels.as_chunks::<4>().0.iter().flat_map(|px| px[..3].iter().copied()).collect(); + assert_eq!(rgb, &want, "pages {pages:?} draw their own backdrop"); + assert!(alpha.is_none()); + } +} + +/// The block-skipping pixel conversions give exactly the per-pixel formulas, in blocks that skip +/// and blocks that don't, and in a tail shorter than a block. +#[test] +fn backdrop_and_content_pixels_convert_per_pixel() { + let mut px: Vec = Vec::new(); + for a in 0..=255u8 { + for c in 0..=255u8 { + px.extend([c, c / 2, 255 - c, a]); + } + } + px.extend([0, 0, 0, 0].repeat(32)); + px.extend([9, 8, 7, 255].repeat(32)); + px.extend([1, 2, 3, 128, 4, 5, 6, 0]); + let (mut o, mut s) = (px.clone(), px.clone()); + opaque(&mut o); + straight(&mut s); + for ((p, o), s) in px.chunks(4).zip(o.chunks(4)).zip(s.chunks(4)) { + let a = p[3] as u32; + for k in 0..3 { + assert_eq!(o[k] as u32, (p[k] as u32 + 255 - a).min(255), "{p:?}"); + let want = if a == 0 || a == 255 { p[k] as u32 } else { ((p[k] as u32 * 255 + a / 2) / a).min(255) }; + assert_eq!(s[k] as u32, want, "{p:?}"); + } + assert_eq!((o[3], s[3]), (255, p[3])); + } } #[test] diff --git a/crates/pptx/src/read.rs b/crates/pptx/src/read.rs index bb717d45..18e8eb92 100644 --- a/crates/pptx/src/read.rs +++ b/crates/pptx/src/read.rs @@ -413,7 +413,56 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } let pres_part = Part::new(&pkg, &pres_path, &pres_doc); - // Masters, layouts and slides to read. + let (master_paths, slide_entries) = master_and_slide_paths(pres_el, &pres_part); + let (docs, max_id) = load_part_docs(&pkg, &master_paths, &slide_entries); + + let mut imp = Imp { + pkg: &pkg, + media: vec![], + media_by_part: HashMap::new(), + next: max_id.max(1), + slide_by_part: HashMap::new(), + custom_show_names: HashMap::new(), + table_style_ids: Vec::new(), + vml_previews: HashMap::new(), + kept_sidecar_bytes: 0, + kept_charts: HashMap::new(), + }; + // Slide ids first so hyperlinks anywhere can point at slides. + let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides); + read_custom_show_names(&mut imp, pres_el); + + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + p.masters.clear(); + p.media.clear(); + p.default_text_style = Default::default(); + + read_presentation_settings(&mut imp, &mut p, &pres_part, pres_el); + let layout_by_part = read_masters(&mut imp, &mut p, &master_paths, &docs); + let file_slide_ids = read_slides(&mut imp, &mut p, &pres_part, &docs, &slide_ids, &layout_by_part); + read_sections(&mut p, &pres_part, pres_el, &file_slide_ids); + p.tags = tags(&imp, &pres_part, pres_el); + read_custom_shows(&mut p, &imp, &pres_part, pres_el); + read_guides_and_show(&mut p, &imp, &pres_part); + read_notes_master(&mut imp, &mut p, &pres_part); + read_handout_master(&mut imp, &mut p, &pres_part); + read_document_properties(&mut p, &pkg, &root_rels); + + p.kept_parts = kept_parts(&pkg, &pres_path); + + inherit_placeholder_geometry(&mut p); + default_text_anchor(&mut p); + p.header_footer = header_footer(&p); + p.embedded_fonts = read_embedded_fonts(&mut imp, &pres_part, pres_el); + p.media = std::mem::take(&mut imp.media); + p.next_id = imp.next; + p.fix_next_id(); + Ok((p, lost_slides)) +} + +/// Masters, layouts and slides to read, in file order and without duplicates: (master paths, +/// (file slide id, slide path)). +fn master_and_slide_paths(pres_el: &El, pres_part: &Part) -> (Vec, Vec<(u32, String)>) { let master_paths: Vec = pres_el .child("sldMasterIdLst") .map(|l| l.children_named("sldMasterId").filter_map(|m| pres_part.rels.target(m.attr("r:id").unwrap_or(""))).map(String::from).collect()) @@ -429,15 +478,19 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE .collect() }) .unwrap_or_default(); - - let mut docs: HashMap = HashMap::new(); - let mut max_id = 0u32; let mut seen_masters = HashSet::new(); master_paths.retain(|m| seen_masters.insert(opc::norm(m))); + let mut seen_slides = HashSet::new(); + slide_entries.retain(|(_, p)| seen_slides.insert(opc::norm(p))); + (master_paths, slide_entries) +} - let mut all_parts: Vec = master_paths.clone(); +/// Loads every master, layout and slide part once. Returns them by normalized path and the +/// largest shape id found in them. +fn load_part_docs(pkg: &Package, master_paths: &[String], slide_entries: &[(u32, String)]) -> (HashMap, u32) { + let mut all_parts: Vec = master_paths.to_vec(); let mut seen_layouts = HashSet::new(); - for m in &master_paths { + for m in master_paths { for r in pkg.rels(m).all_of("slideLayout") { let key = opc::norm(&r.target); if seen_layouts.insert(key) { @@ -445,35 +498,32 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } } } - let mut seen_slides = HashSet::new(); - slide_entries.retain(|(_, p)| seen_slides.insert(opc::norm(p))); all_parts.extend(slide_entries.iter().map(|(_, p)| p.clone())); + let mut docs: HashMap = HashMap::new(); + let mut max_id = 0u32; for p in &all_parts { let key = opc::norm(p); if docs.contains_key(&key) { continue; } - if let Some(d) = load(&pkg, p) { + if let Some(d) = load(pkg, p) { max_cnvpr(&d.root, &mut max_id); docs.insert(key, d); } } + (docs, max_id) +} - let mut imp = Imp { - pkg: &pkg, - media: vec![], - media_by_part: HashMap::new(), - next: max_id.max(1), - slide_by_part: HashMap::new(), - custom_show_names: HashMap::new(), - table_style_ids: Vec::new(), - vml_previews: HashMap::new(), - kept_sidecar_bytes: 0, - kept_charts: HashMap::new(), - }; - // Slide ids first so hyperlinks anywhere can point at slides. +/// Allocates an id for every readable slide; slides that are missing or unreadable are recorded in +/// `lost_slides` and left out. +fn register_slides( + imp: &mut Imp, + docs: &HashMap, + slide_entries: &[(u32, String)], + lost_slides: &mut Vec, +) -> Vec<(u32, String, SlideId)> { let mut slide_ids: Vec<(u32, String, SlideId)> = vec![]; - for (file_id, path) in &slide_entries { + for (file_id, path) in slide_entries { if !docs.contains_key(&opc::norm(path)) { log::warn!("pptx: slide {path} is missing or unreadable; skipped"); lost_slides.push(path.clone()); @@ -483,6 +533,10 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE imp.slide_by_part.insert(opc::norm(path), id); slide_ids.push((*file_id, path.clone(), id)); } + slide_ids +} + +fn read_custom_show_names(imp: &mut Imp, pres_el: &El) { if let Some(l) = pres_el.child("custShowLst") { for cs in l.children_named("custShow") { if let (Some(id), Some(name)) = (cs.attr("id"), cs.attr("name")) { @@ -490,12 +544,10 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } } } +} - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - p.masters.clear(); - p.media.clear(); - p.default_text_style = Default::default(); - +/// Slide and notes size, first slide number, default text style and table styles. +fn read_presentation_settings(imp: &mut Imp, p: &mut Presentation, pres_part: &Part, pres_el: &El) { if let Some(sz) = pres_el.child("sldSz") { let w = sz.i64("cx").map(emu_to_pt).unwrap_or(960.0); let h = sz.i64("cy").map(emu_to_pt).unwrap_or(540.0); @@ -508,24 +560,26 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } p.first_slide_number = pres_el.u32("firstSlideNum").unwrap_or(1).min(9999); if let Some(dts) = pres_el.child("defaultTextStyle") { - p.default_text_style = dml::list_style(&mut imp, &pres_part, dts); + p.default_text_style = dml::list_style(imp, pres_part, dts); } if let Some(r) = pres_part.rels.first_of("tableStyles") - && let Some(d) = load(&pkg, &r.target) + && let Some(d) = load(imp.pkg, &r.target) { - let part = Part::new(&pkg, &r.target, &d); - p.table_styles = table_styles(&mut imp, &part, &d.root); + let part = Part::new(imp.pkg, &r.target, &d); + p.table_styles = table_styles(imp, &part, &d.root); imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect(); } +} - // Masters and layouts. +/// Reads the slide masters and their layouts; a file without a readable master gets a default one. +fn read_masters(imp: &mut Imp, p: &mut Presentation, master_paths: &[String], docs: &HashMap) -> HashMap { let mut layout_by_part: HashMap = HashMap::new(); - for mp in &master_paths { + for mp in master_paths { let Some(doc) = docs.get(&opc::norm(mp)) else { log::warn!("pptx: master {mp} missing; skipped"); continue; }; - let master = read_master(&mut imp, mp, doc, &docs, &mut layout_by_part); + let master = read_master(imp, mp, doc, docs, &mut layout_by_part); p.masters.push(Arc::new(master)); } if p.masters.is_empty() { @@ -534,87 +588,111 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE imp.next = imp.next.saturating_add(200); p.masters.push(Arc::new(m)); } - let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); + layout_by_part +} - // Slides. +/// Reads every registered slide with its notes and comments. Returns the slide ids by file id. +fn read_slides( + imp: &mut Imp, + p: &mut Presentation, + pres_part: &Part, + docs: &HashMap, + slide_ids: &[(u32, String, SlideId)], + layout_by_part: &HashMap, +) -> HashMap { + let pkg = imp.pkg; + let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); let mut file_slide_ids: HashMap = HashMap::new(); let mut notes_by_target: HashMap = HashMap::new(); - for (file_id, path, id) in &slide_ids { + for (file_id, path, id) in slide_ids { let Some(doc) = docs.get(&opc::norm(path)) else { continue }; - let part = Part::new(&pkg, path, doc); + let part = Part::new(pkg, path, doc); let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout); - let mut slide = read_slide(&mut imp, &part, doc, *id, layout); - // Notes. - if let Some(n) = part.rels.first_of("notesSlide") { - let n_key = opc::norm(&n.target); - if let Some(cached_notes) = notes_by_target.get(&n_key) { - slide.notes = cached_notes.clone(); - } else if let Some(nd) = load(&pkg, &n.target) { - let np = Part::new(&pkg, &n.target, &nd); - let notes = read_notes(&mut imp, &np, &nd.root); - notes_by_target.insert(n_key, notes.clone()); - slide.notes = notes; - } - } - // Comments. A modern part and the legacy part can describe the same threads (PowerPoint - // writes both): the modern one wins, its flattened legacy copy is skipped. - let mut parts: Vec<(Doc, bool)> = vec![]; - for c in part.rels.all_of("comments") { - if let Some(cd) = pkg.xml(&c.target) { - // The content type names the modern part; a package that types it only through a - // Default entry instead of an Override is still modern when its root is p188. - let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188"; - parts.push((cd, modern)); - } - } - let has_modern = parts.iter().any(|(_, m)| *m); - for (cd, modern) in parts { - if has_modern && !modern { - continue; - } - slide.comments.extend(read_comments(&pkg, &pres_part, &cd.root, modern)); + let mut slide = read_slide(imp, &part, doc, *id, layout); + if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) { + slide.notes = notes; } + read_slide_comments(pkg, pres_part, &part, &mut slide); file_slide_ids.insert(*file_id, *id); p.slides.push(Arc::new(slide)); } + file_slide_ids +} - // Sections. - if let Some(ext) = pres_el.child("extLst") { - if let Some(sl) = ext.find("sectionLst") { - for s in sl.children_named("section").take(10_000) { - let slides = s - .child("sldIdLst") - .map(|l| l.children_named("sldId").filter_map(|x| x.u32("id").and_then(|i| file_slide_ids.get(&i).copied())).collect()) - .unwrap_or_default(); - p.sections.push(Section { name: s.attr("name").unwrap_or("").to_string(), slides }); - } - } - let kept: String = ext - .children_named("ext") - .filter(|e| e.attr("uri") != Some(crate::SECTION_EXT_URI) && !has_rel_refs(e)) - .map(|e| pres_part.keep(e)) - .collect(); - if !kept.is_empty() { - p.raw_ext = Some(kept); +/// The slide's notes text, read once per notes part. +fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap) -> Option { + let n = part.rels.first_of("notesSlide")?; + let key = opc::norm(&n.target); + if let Some(cached) = cache.get(&key) { + return Some(cached.clone()); + } + let pkg = imp.pkg; + let nd = load(pkg, &n.target)?; + let np = Part::new(pkg, &n.target, &nd); + let notes = read_notes(imp, &np, &nd.root); + cache.insert(key, notes.clone()); + Some(notes) +} + +/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint +/// writes both): the modern one wins, its flattened legacy copy is skipped. +fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) { + let mut parts: Vec<(Doc, bool)> = vec![]; + for c in part.rels.all_of("comments") { + if let Some(cd) = pkg.xml(&c.target) { + // The content type names the modern part; a package that types it only through a + // Default entry instead of an Override is still modern when its root is p188. + let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188"; + parts.push((cd, modern)); } } - p.tags = tags(&imp, &pres_part, pres_el); - // Custom shows. - if let Some(l) = pres_el.child("custShowLst") { - for cs in l.children_named("custShow") { - let slides = cs - .child("sldLst") - .map(|l| { - l.children_named("sld") - .filter_map(|s| pres_part.rels.target(s.attr("r:id").unwrap_or(""))) - .filter_map(|t| imp.slide_by_part.get(&opc::norm(t)).copied()) - .collect() - }) + let has_modern = parts.iter().any(|(_, m)| *m); + for (cd, modern) in parts { + if has_modern && !modern { + continue; + } + slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern)); + } +} + +/// Sections, and the extension elements the model does not understand (kept verbatim). +fn read_sections(p: &mut Presentation, pres_part: &Part, pres_el: &El, file_slide_ids: &HashMap) { + let Some(ext) = pres_el.child("extLst") else { return }; + if let Some(sl) = ext.find("sectionLst") { + for s in sl.children_named("section").take(10_000) { + let slides = s + .child("sldIdLst") + .map(|l| l.children_named("sldId").filter_map(|x| x.u32("id").and_then(|i| file_slide_ids.get(&i).copied())).collect()) .unwrap_or_default(); - p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides }); + p.sections.push(Section { name: s.attr("name").unwrap_or("").to_string(), slides }); } } - // Drawing guides (eighths of a point). + let kept: String = + ext.children_named("ext").filter(|e| e.attr("uri") != Some(crate::SECTION_EXT_URI) && !has_rel_refs(e)).map(|e| pres_part.keep(e)).collect(); + if !kept.is_empty() { + p.raw_ext = Some(kept); + } +} + +fn read_custom_shows(p: &mut Presentation, imp: &Imp, pres_part: &Part, pres_el: &El) { + let Some(l) = pres_el.child("custShowLst") else { return }; + for cs in l.children_named("custShow") { + let slides = cs + .child("sldLst") + .map(|l| { + l.children_named("sld") + .filter_map(|s| pres_part.rels.target(s.attr("r:id").unwrap_or(""))) + .filter_map(|t| imp.slide_by_part.get(&opc::norm(t)).copied()) + .collect() + }) + .unwrap_or_default(); + p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides }); + } +} + +/// Drawing guides (eighths of a point) and slide show settings. +fn read_guides_and_show(p: &mut Presentation, imp: &Imp, pres_part: &Part) { + let pkg = imp.pkg; if let Some(r) = pres_part.rels.first_of("viewProps") && let Some(d) = pkg.xml(&r.target) && let Some(lst) = d.root.find("slideViewPr").and_then(|v| v.find("guideLst")) @@ -628,71 +706,76 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE .take(1000) .collect(); } - // Show settings. if let Some(r) = pres_part.rels.first_of("presProps") && let Some(d) = pkg.xml(&r.target) && let Some(sp) = d.root.child("showPr") { p.show = read_show(sp, &imp.custom_show_names); } - // Notes master. - if let Some(r) = pres_part.rels.first_of("notesMaster") - && let Some(d) = load(&pkg, &r.target) - { - let part = Part::new(&pkg, &r.target, &d); - let mut ctx = shapes::IdCtx::default(); - let mut m = Master { id: MasterId(imp.alloc()), name: "Notes Master".into(), ..Default::default() }; - if let Some(t) = part.rels.first_of("theme") - && let Some(td) = pkg.xml(&t.target) - { - let tp = Part::new(&pkg, &t.target, &td); - m.theme = theme::read_theme(&mut imp, &tp, &td.root); +} + +/// Theme, background, shapes and colour map shared by the notes and handout masters. +fn read_aux_master(imp: &mut Imp, part: &Part, doc: &Doc, m: &mut Master) { + let mut ctx = shapes::IdCtx::default(); + if let Some(csld) = doc.root.child("cSld") { + m.background = background(imp, part, csld); + if let Some(tree) = csld.child("spTree") { + m.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); } - if let Some(csld) = d.root.child("cSld") { - m.background = background(&mut imp, &part, csld); - if let Some(tree) = csld.child("spTree") { - m.shapes = shapes::sp_tree(&mut imp, &part, &mut ctx, tree, 0); - } - } - if let Some(cm) = d.root.child("clrMap") { - m.color_map = color_map(cm); - } - // Notes text styles are the body's list style; slide masters use title/body/other instead. - if let Some(ns) = d.root.child("notesStyle") { - m.body_style = dml::list_style(&mut imp, &part, ns); - } - p.notes_master = Some(Arc::new(m)); } - // Handout master (header, footer, date and slide number for printed handouts). - if let Some(r) = pres_part.rels.first_of("handoutMaster") - && let Some(d) = load(&pkg, &r.target) - { - let part = Part::new(&pkg, &r.target, &d); - let mut ctx = shapes::IdCtx::default(); - let mut m = Master { id: MasterId(imp.alloc()), name: "Handout Master".into(), ..Default::default() }; - if let Some(t) = part.rels.first_of("theme") - && let Some(td) = pkg.xml(&t.target) - { - let tp = Part::new(&pkg, &t.target, &td); - m.theme = theme::read_theme(&mut imp, &tp, &td.root); - } else if let Some(first) = p.masters.first() { - m.theme = first.theme.clone(); - } - if let Some(csld) = d.root.child("cSld") { - if let Some(n) = csld.attr("name").filter(|s| !s.is_empty()) { - m.name = n.to_string(); - } - m.background = background(&mut imp, &part, csld); - if let Some(tree) = csld.child("spTree") { - m.shapes = shapes::sp_tree(&mut imp, &part, &mut ctx, tree, 0); - } - } - if let Some(cm) = d.root.child("clrMap") { - m.color_map = color_map(cm); - } - p.handout_master = Some(Arc::new(m)); + if let Some(cm) = doc.root.child("clrMap") { + m.color_map = color_map(cm); } - // Document properties. +} + +/// Reads the part's own theme into the master, if it has one. +fn read_master_theme(imp: &mut Imp, part: &Part, m: &mut Master) -> bool { + let pkg = imp.pkg; + if let Some(t) = part.rels.first_of("theme") + && let Some(td) = pkg.xml(&t.target) + { + let tp = Part::new(pkg, &t.target, &td); + m.theme = theme::read_theme(imp, &tp, &td.root); + return true; + } + false +} + +fn read_notes_master(imp: &mut Imp, p: &mut Presentation, pres_part: &Part) { + let pkg = imp.pkg; + let Some(r) = pres_part.rels.first_of("notesMaster") else { return }; + let Some(d) = load(pkg, &r.target) else { return }; + let part = Part::new(pkg, &r.target, &d); + let mut m = Master { id: MasterId(imp.alloc()), name: "Notes Master".into(), ..Default::default() }; + read_master_theme(imp, &part, &mut m); + read_aux_master(imp, &part, &d, &mut m); + // Notes text styles are the body's list style; slide masters use title/body/other instead. + if let Some(ns) = d.root.child("notesStyle") { + m.body_style = dml::list_style(imp, &part, ns); + } + p.notes_master = Some(Arc::new(m)); +} + +/// Handout master (header, footer, date and slide number for printed handouts). +fn read_handout_master(imp: &mut Imp, p: &mut Presentation, pres_part: &Part) { + let pkg = imp.pkg; + let Some(r) = pres_part.rels.first_of("handoutMaster") else { return }; + let Some(d) = load(pkg, &r.target) else { return }; + let part = Part::new(pkg, &r.target, &d); + let mut m = Master { id: MasterId(imp.alloc()), name: "Handout Master".into(), ..Default::default() }; + if !read_master_theme(imp, &part, &mut m) + && let Some(first) = p.masters.first() + { + m.theme = first.theme.clone(); + } + if let Some(n) = d.root.child("cSld").and_then(|c| c.attr("name")).filter(|s| !s.is_empty()) { + m.name = n.to_string(); + } + read_aux_master(imp, &part, &d, &mut m); + p.handout_master = Some(Arc::new(m)); +} + +fn read_document_properties(p: &mut Presentation, pkg: &Package, root_rels: &Rels) { if let Some(r) = root_rels.first_of("core-properties") && let Some(d) = pkg.xml(&r.target) { @@ -703,17 +786,6 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE { p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default(); } - - p.kept_parts = kept_parts(&pkg, &pres_path); - - inherit_placeholder_geometry(&mut p); - default_text_anchor(&mut p); - p.header_footer = header_footer(&p); - p.embedded_fonts = read_embedded_fonts(&mut imp, &pres_part, pres_el); - p.media = std::mem::take(&mut imp.media); - p.next_id = imp.next; - p.fix_next_id(); - Ok((p, lost_slides)) } /// Embedded font faces in one file. Past this, further typefaces are dropped. diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 76158af3..540aaff1 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -129,167 +129,22 @@ fn chart_within(root: &El, mut budget: usize) -> Option { // (a combo chart's line on the right) is on the secondary axis. let val_map: std::collections::HashMap<&str, bool> = pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, matches!(a.child_val("axPos"), Some("l" | "r"))))).collect(); - // A group's value axis: its vertical one when it has two (scatter), else its one. - let group_val_axis = |g: &El| { - let ids: Vec<(&str, bool)> = - g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_map.get(id).copied().map(|v| (id, v))).collect(); - ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string()) - }; - // The primary value axis is the one on the left (bottom for bar charts); the secondary one - // sits on the right or top, or crosses the category axis at its maximum. - let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t")); - let val_axes_by_id: std::collections::HashMap<&str, &El> = pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, a))).collect(); - let used: Vec = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect(); - let primary_val: Option = - used.iter().find(|id| val_axes_by_id.get(id.as_str()).is_some_and(|a| !secondary_placed(a))).or(used.first()).cloned(); + let primary_val = primary_value_axis(pa, &val_map); for g in pa.elements() { let name = g.local(); if !name.ends_with("Chart") { continue; } - let grouping = g.child_val("grouping").unwrap_or("clustered"); - let kind = match name { - "barChart" | "bar3DChart" => { - let bar = g.child_val("barDir") == Some("bar"); - match (bar, grouping) { - (false, "stacked") => ChartType::StackedColumn, - (false, "percentStacked") => ChartType::PercentColumn, - (false, _) => ChartType::Column, - (true, "stacked") => ChartType::StackedBar, - (true, "percentStacked") => ChartType::PercentBar, - (true, _) => ChartType::Bar, - } - } - "lineChart" | "line3DChart" => { - let markers = g.child_val("marker").is_some_and(|v| v == "1" || v == "true") - && g.children_named("ser").all(|s| s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")) != Some("none")); - match grouping { - "stacked" | "percentStacked" => ChartType::StackedLine, - _ if markers => ChartType::LineMarkers, - _ => ChartType::Line, - } - } - "areaChart" | "area3DChart" => { - if matches!(grouping, "stacked" | "percentStacked") { - ChartType::StackedArea - } else { - ChartType::Area - } - } - "pieChart" | "pie3DChart" | "ofPieChart" => ChartType::Pie, - "doughnutChart" => ChartType::Doughnut, - "scatterChart" => ChartType::Scatter, - "bubbleChart" => ChartType::Bubble, - "radarChart" => { - if g.child_val("radarStyle") == Some("filled") { - ChartType::FilledRadar - } else { - ChartType::Radar - } - } - "stockChart" => { - c.up_down_bars = g.child("upDownBars").is_some(); - ChartType::Stock - } - "surfaceChart" | "surface3DChart" => ChartType::Surface, - _ => continue, - }; - // A missing means "vary by point" (the schema default is true), as PowerPoint draws it. - c.vary_colors = g.child_val("varyColors").is_none_or(|v| v == "1" || v == "true"); - // A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %), - // not the 219 % / -27 % PowerPoint writes into its new charts. - let bar_group = g.is("barChart") || g.is("bar3DChart"); - if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { - c.gap_width = (v / 100.0).clamp(0.0, 5.0); - } else if bar_group { - c.gap_width = 1.5; - } - if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::().ok()) { - c.overlap = (v / 100.0).clamp(-1.0, 1.0); - } else if bar_group { - c.overlap = 0.0; - } - if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::().ok()) { - c.hole_size = (v / 100.0).clamp(0.0, 0.9); - } - // `` without a val attribute means true, as in the schema's default. - // The setting belongs to this plot group: a group that asks for no labels keeps them - // off, even when another group of a combo chart asks for them (#868). - let group_labels = g.path(&["dLbls", "showVal"]).map(|v| v.bool("val").unwrap_or(true)); - if group_labels == Some(true) { - c.data_labels = true; + let Some(kind) = group_chart_type(name, g) else { continue }; + if name == "stockChart" { + c.up_down_bars = g.child("upDownBars").is_some(); } + let group_labels = read_group_settings(g, &mut c); kinds.push(kind); - let val_axis = group_val_axis(g); + let val_axis = group_value_axis(&val_map, g); let secondary = val_axis.is_some() && val_axis != primary_val; for s in g.children_named("ser").take(1000) { - let mut ser = Series { - name: s.child("tx").map(|t| strings(t, &mut budget)).and_then(|v| v.into_iter().next()).unwrap_or_default(), - fill: simple_fill(s.child("spPr")), - line: simple_line(s.child("spPr")), - smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false), - marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from), - kind: Some(kind), - secondary_axis: secondary, - ..Default::default() - }; - // `` without a val attribute means true, as in the schema's default. - if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) { - ser.data_labels = Some(d); - } else if let Some(false) = group_labels { - // The group asks for no labels and the series does not say otherwise. - ser.data_labels = Some(false); - } - for t in s.children_named("trendline").take(16) { - if let Some(t) = trendline(t) { - ser.trendlines.push(t); - } - } - // Data labels' number format: the series' labels', the group's, else the source data's. - let label_fmt = |d: Option<&El>| { - d.and_then(|d| d.child("numFmt")) - .filter(|n| n.attr("sourceLinked") != Some("1")) - .and_then(|n| n.attr("formatCode")) - .map(str::to_string) - }; - let source = - s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text()); - ser.number_format = label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty()); - for dp in s.children_named("dPt") { - if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) { - ser.point_fills.push((i as usize, f)); - } - } - if let Some(cat) = s.child("cat").or_else(|| s.child("xVal")) { - let cats = strings(cat, &mut budget); - if matches!(kind, ChartType::Scatter | ChartType::Bubble) { - ser.x_values = cats.iter().map(|v| v.trim().parse::().ok()).collect(); - } - if c.categories.is_empty() { - c.categories = cats; - } - } - if let Some(v) = s.child("val").or_else(|| s.child("yVal")) { - ser.values = numbers(v, &mut budget); - } - // Error bars on the values: a scatter chart's y bars, any other chart's only ones. - if let Some(e) = s - .children_named("errBars") - .find(|e| e.child_val("errDir").is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble))) - { - let d = ErrorBars::default(); - ser.error_bars = Some(ErrorBars { - bar_type: e.child_val("errBarType").unwrap_or("both").to_string(), - val_type: e.child_val("errValType").unwrap_or("fixedVal").to_string(), - value: e.child_val("val").and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()).unwrap_or(d.value), - plus: e.child("plus").map(|p| numbers(p, &mut budget)).unwrap_or_default(), - minus: e.child("minus").map(|p| numbers(p, &mut budget)).unwrap_or_default(), - end_cap: e.child("noEndCap").and_then(|v| v.bool("val")) != Some(true), - }); - } - if let Some(b) = s.child("bubbleSize") { - ser.sizes = numbers(b, &mut budget); - } + let ser = read_series(s, g, (kind, secondary, group_labels), &mut c.categories, &mut budget); c.series.push(ser); } } @@ -305,11 +160,189 @@ fn chart_within(root: &El, mut budget: usize) -> Option { s.secondary_axis = false; } } - // Axes. c.lang = root.child_val("lang").map(str::to_string); + let (val_ax, cat_ax) = read_axes(&mut c, pa, &primary_val); + read_text_styles(&mut c, root, ch, val_ax, cat_ax); + Some(c) +} + +/// A group's value axis: its vertical one when it has two (scatter), else its one. +fn group_value_axis(val_map: &std::collections::HashMap<&str, bool>, g: &El) -> Option { + let ids: Vec<(&str, bool)> = + g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_map.get(id).copied().map(|v| (id, v))).collect(); + ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string()) +} + +/// The primary value axis is the one on the left (bottom for bar charts); the secondary one +/// sits on the right or top, or crosses the category axis at its maximum. +fn primary_value_axis(pa: &El, val_map: &std::collections::HashMap<&str, bool>) -> Option { + let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t")); + let val_axes_by_id: std::collections::HashMap<&str, &El> = pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, a))).collect(); + let used: Vec = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(|g| group_value_axis(val_map, g)).collect(); + used.iter().find(|id| val_axes_by_id.get(id.as_str()).is_some_and(|a| !secondary_placed(a))).or(used.first()).cloned() +} + +/// The chart type of a plot group (`name` is its element name), or `None` for an unknown group. +fn group_chart_type(name: &str, g: &El) -> Option { + let grouping = g.child_val("grouping").unwrap_or("clustered"); + Some(match name { + "barChart" | "bar3DChart" => { + let bar = g.child_val("barDir") == Some("bar"); + match (bar, grouping) { + (false, "stacked") => ChartType::StackedColumn, + (false, "percentStacked") => ChartType::PercentColumn, + (false, _) => ChartType::Column, + (true, "stacked") => ChartType::StackedBar, + (true, "percentStacked") => ChartType::PercentBar, + (true, _) => ChartType::Bar, + } + } + "lineChart" | "line3DChart" => { + let markers = g.child_val("marker").is_some_and(|v| v == "1" || v == "true") + && g.children_named("ser").all(|s| s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")) != Some("none")); + match grouping { + "stacked" | "percentStacked" => ChartType::StackedLine, + _ if markers => ChartType::LineMarkers, + _ => ChartType::Line, + } + } + "areaChart" | "area3DChart" => { + if matches!(grouping, "stacked" | "percentStacked") { + ChartType::StackedArea + } else { + ChartType::Area + } + } + "pieChart" | "pie3DChart" | "ofPieChart" => ChartType::Pie, + "doughnutChart" => ChartType::Doughnut, + "scatterChart" => ChartType::Scatter, + "bubbleChart" => ChartType::Bubble, + "radarChart" => { + if g.child_val("radarStyle") == Some("filled") { + ChartType::FilledRadar + } else { + ChartType::Radar + } + } + "stockChart" => ChartType::Stock, + "surfaceChart" | "surface3DChart" => ChartType::Surface, + _ => return None, + }) +} + +/// Chart-wide settings a plot group carries (vary colours, gap, overlap, hole, data labels). +/// Returns the group's own data-label setting. +fn read_group_settings(g: &El, c: &mut Chart) -> Option { + // A missing means "vary by point" (the schema default is true), as PowerPoint draws it. + c.vary_colors = g.child_val("varyColors").is_none_or(|v| v == "1" || v == "true"); + // A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %), + // not the 219 % / -27 % PowerPoint writes into its new charts. + let bar_group = g.is("barChart") || g.is("bar3DChart"); + if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { + c.gap_width = (v / 100.0).clamp(0.0, 5.0); + } else if bar_group { + c.gap_width = 1.5; + } + if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::().ok()) { + c.overlap = (v / 100.0).clamp(-1.0, 1.0); + } else if bar_group { + c.overlap = 0.0; + } + if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::().ok()) { + c.hole_size = (v / 100.0).clamp(0.0, 0.9); + } + // `` without a val attribute means true, as in the schema's default. + // The setting belongs to this plot group: a group that asks for no labels keeps them + // off, even when another group of a combo chart asks for them (#868). + let group_labels = g.path(&["dLbls", "showVal"]).map(|v| v.bool("val").unwrap_or(true)); + if group_labels == Some(true) { + c.data_labels = true; + } + group_labels +} + +/// Data labels' number format: the series' labels', the group's, else the source data's. +fn label_number_format(s: &El, g: &El) -> Option { + let label_fmt = |d: Option<&El>| { + d.and_then(|d| d.child("numFmt")).filter(|n| n.attr("sourceLinked") != Some("1")).and_then(|n| n.attr("formatCode")).map(str::to_string) + }; + let source = s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text()); + label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty()) +} + +/// Error bars on the values: a scatter chart's y bars, any other chart's only ones. +fn read_error_bars(s: &El, kind: ChartType, budget: &mut usize) -> Option { + let e = s + .children_named("errBars") + .find(|e| e.child_val("errDir").is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble)))?; + let d = ErrorBars::default(); + Some(ErrorBars { + bar_type: e.child_val("errBarType").unwrap_or("both").to_string(), + val_type: e.child_val("errValType").unwrap_or("fixedVal").to_string(), + value: e.child_val("val").and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()).unwrap_or(d.value), + plus: e.child("plus").map(|p| numbers(p, budget)).unwrap_or_default(), + minus: e.child("minus").map(|p| numbers(p, budget)).unwrap_or_default(), + end_cap: e.child("noEndCap").and_then(|v| v.bool("val")) != Some(true), + }) +} + +/// One `c:ser` of group `g`. `group` is (chart type, on the secondary axis, the group's data-label +/// setting); the first series with categories sets `categories`. +fn read_series(s: &El, g: &El, group: (ChartType, bool, Option), categories: &mut Vec, budget: &mut usize) -> Series { + let (kind, secondary, group_labels) = group; + let mut ser = Series { + name: s.child("tx").map(|t| strings(t, budget)).and_then(|v| v.into_iter().next()).unwrap_or_default(), + fill: simple_fill(s.child("spPr")), + line: simple_line(s.child("spPr")), + smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false), + marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from), + kind: Some(kind), + secondary_axis: secondary, + ..Default::default() + }; + // `` without a val attribute means true, as in the schema's default. + if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) { + ser.data_labels = Some(d); + } else if let Some(false) = group_labels { + // The group asks for no labels and the series does not say otherwise. + ser.data_labels = Some(false); + } + for t in s.children_named("trendline").take(16) { + if let Some(t) = trendline(t) { + ser.trendlines.push(t); + } + } + ser.number_format = label_number_format(s, g); + for dp in s.children_named("dPt") { + if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) { + ser.point_fills.push((i as usize, f)); + } + } + if let Some(cat) = s.child("cat").or_else(|| s.child("xVal")) { + let cats = strings(cat, budget); + if matches!(kind, ChartType::Scatter | ChartType::Bubble) { + ser.x_values = cats.iter().map(|v| v.trim().parse::().ok()).collect(); + } + if categories.is_empty() { + *categories = cats; + } + } + if let Some(v) = s.child("val").or_else(|| s.child("yVal")) { + ser.values = numbers(v, budget); + } + ser.error_bars = read_error_bars(s, kind, budget); + if let Some(b) = s.child("bubbleSize") { + ser.sizes = numbers(b, budget); + } + ser +} + +/// Axis scaling, titles, number formats and visibility. Returns the primary value axis and the +/// category axis. +fn read_axes<'a>(c: &mut Chart, pa: &'a El, primary_val: &Option) -> (Option<&'a El>, Option<&'a El>) { let vertical = |a: &&El| matches!(a.child_val("axPos"), Some("l" | "r")); let by_id = |id: &Option| pa.children_named("valAx").find(|a| id.is_some() && a.child_val("axId") == id.as_deref()); - let val_ax = by_id(&primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last()); + let val_ax = by_id(primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last()); let num = |a: &El, k: &str| a.child_val(k).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()); // An axis' number format: its own unless linked to the source (`sourceLinked="1"`, or no // c:numFmt at all), in which case the data's format of the first series plotted on it. @@ -357,6 +390,11 @@ fn chart_within(root: &El, mut budget: usize) -> Option { c.cat_reversed = a.child("scaling").and_then(|s| s.child_val("orientation")).is_some_and(|o| o == "maxMin"); c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); } + (val_ax, cat_ax) +} + +/// Text sizes and colours as the file sets them. +fn read_text_styles(c: &mut Chart, root: &El, ch: &El, val_ax: Option<&El>, cat_ax: Option<&El>) { // Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to // the chart): the axes' and legend's, else the whole chart's; the title's own. let chart_size = text_size(root.child("txPr")); @@ -364,7 +402,6 @@ fn chart_within(root: &El, mut budget: usize) -> Option { // Text colour likewise: the axes' and legend's, else the whole chart's. c.text_color = [val_ax, cat_ax, ch.child("legend"), Some(root)].into_iter().flatten().find_map(|e| text_color(e.child("txPr"))); c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr")))); - Some(c) } fn trend_num(e: &El, k: &str) -> Option { diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 04ae042e..28e62fe9 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -1,7 +1,7 @@ //! Chart parts: a `c:chartSpace` with cached categories and values. //! An unchanged chart also keeps the embedded workbook and the other parts it points at. -use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType}; +use deckcraft_model::chart::{Chart, ChartType, ErrorBars, Series, Trendline, TrendlineType}; use deckcraft_model::style::Fill; use crate::opc::{NS_A, NS_C, NS_R}; @@ -204,6 +204,48 @@ fn group_of(k: ChartType) -> Group { } } +fn error_bars(w: &mut W, e: &ErrorBars, g: Group) { + let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d); + w.open0("c:errBars"); + if matches!(g, Group::Scatter | Group::Bubble) { + w.val("c:errDir", "y"); + } + w.val("c:errBarType", word(&e.bar_type, &["both", "plus", "minus"], "both")); + let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal"); + w.val("c:errValType", vt); + w.val("c:noEndCap", if e.end_cap { 0 } else { 1 }); + if vt == "cust" { + w.open0("c:plus"); + num_lit(w, &e.plus); + w.close("c:plus"); + w.open0("c:minus"); + num_lit(w, &e.minus); + w.close("c:minus"); + } else if e.value.is_finite() { + w.val("c:val", fmt_num(e.value)); + } + w.close("c:errBars"); +} + +/// Per-point fills of pie, doughnut and bar series. +fn point_fills(w: &mut W, s: &Series, g: Group) { + if !matches!(g, Group::Pie | Group::Doughnut | Group::Bar) { + return; + } + for (idx, f) in s.point_fills.iter().take(1000) { + w.open0("c:dPt"); + w.val("c:idx", idx); + if g == Group::Bar { + w.val("c:invertIfNegative", 0); + } + if g != Group::Bar { + w.val("c:bubble3D", 0); + } + sp_pr(w, Some(f), None); + w.close("c:dPt"); + } +} + fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) { w.open0("c:ser"); w.val("c:idx", i); @@ -253,20 +295,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) w.close("c:marker"); } } - if g == Group::Pie || g == Group::Doughnut || g == Group::Bar { - for (idx, f) in s.point_fills.iter().take(1000) { - w.open0("c:dPt"); - w.val("c:idx", idx); - if g == Group::Bar { - w.val("c:invertIfNegative", 0); - } - if g != Group::Bar { - w.val("c:bubble3D", 0); - } - sp_pr(w, Some(f), None); - w.close("c:dPt"); - } - } + point_fills(w, s, g); if let Some(d) = s.data_labels { data_labels(w, c, d); } @@ -279,26 +308,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) let n = c.categories.len().max(s.values.len()); let last = n + 1; if let Some(e) = &s.error_bars { - let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d); - w.open0("c:errBars"); - if matches!(g, Group::Scatter | Group::Bubble) { - w.val("c:errDir", "y"); - } - w.val("c:errBarType", word(&e.bar_type, &["both", "plus", "minus"], "both")); - let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal"); - w.val("c:errValType", vt); - w.val("c:noEndCap", if e.end_cap { 0 } else { 1 }); - if vt == "cust" { - w.open0("c:plus"); - num_lit(w, &e.plus); - w.close("c:plus"); - w.open0("c:minus"); - num_lit(w, &e.minus); - w.close("c:minus"); - } else if e.value.is_finite() { - w.val("c:val", fmt_num(e.value)); - } - w.close("c:errBars"); + error_bars(w, e, g); } if matches!(g, Group::Scatter | Group::Bubble) { let xs: Vec> = if s.x_values.is_empty() { (1..=s.values.len()).map(|v| Some(v as f64)).collect() } else { s.x_values.clone() }; diff --git a/crates/pptx/tests/it/corpus_charts.rs b/crates/pptx/tests/it/corpus_charts.rs index ff1e6424..f77d9307 100644 --- a/crates/pptx/tests/it/corpus_charts.rs +++ b/crates/pptx/tests/it/corpus_charts.rs @@ -159,6 +159,11 @@ fn chart_table_and_text_corpus_round_trips() { assert_eq!(a.data_labels, b.data_labels); } + merged_table_survives(slide); + text_columns_and_warp_survive(slide); +} + +fn merged_table_survives(slide: &Slide) { let ShapeKind::Table(t) = &by_name(slide, "Merged table").kind else { panic!("not a table") }; let anchor = t.cell(0, 0).expect("anchor"); assert_eq!((anchor.grid_span, anchor.row_span), (2, 2)); @@ -171,7 +176,9 @@ fn chart_table_and_text_corpus_round_trips() { assert_eq!(diag.diag_down, Some(Line::solid(ColorRef::rgb(Rgba::rgb(180, 40, 40)), 1.5))); assert_eq!(diag.diag_up, Some(Line::solid(ColorRef::rgb(Rgba::rgb(20, 90, 160)), 2.0))); assert_eq!(plain(&diag.text), "Diag"); +} +fn text_columns_and_warp_survive(slide: &Slide) { let cols = by_name(slide, "Columns").text.as_ref().expect("columns"); assert_eq!(plain(cols), "Two columns\nof vertical text"); assert_eq!(cols.body.columns, Some(3)); diff --git a/crates/pptx/tests/it/corpus_elements.rs b/crates/pptx/tests/it/corpus_elements.rs index 7d3a41da..f6bae69a 100644 --- a/crates/pptx/tests/it/corpus_elements.rs +++ b/crates/pptx/tests/it/corpus_elements.rs @@ -215,7 +215,14 @@ fn assert_deck(q: &Presentation) { let inherited = resolve::run(&ctx, body, ¶s[1], ¶s[1].runs[0].props); assert_eq!(inherited.size, Some(28.0), "the second paragraph takes the layout list style"); assert_eq!(inherited.font.as_deref(), Some("Source Serif 4")); + assert_group(q); + assert_comments(q); + assert_click_links(q); + assert_custom_show_links(q); + assert_custom_shows(q); +} +fn assert_group(q: &Presentation) { let cluster = shape(q, 0, "Cluster"); let g = cluster.xfrm.expect("group box"); assert_eq!((g.x, g.y, g.w, g.h, g.rot, g.flip_h), (80.0, 40.0, 220.0, 140.0, 25.0, true)); @@ -225,13 +232,17 @@ fn assert_deck(q: &Presentation) { assert_eq!(gem.text.as_ref().map(|t| t.text()), Some("Tilted".into())); let gx = gem.xfrm.expect("gem"); assert_eq!((gx.x, gx.y, gx.w, gx.h, gx.rot, gx.flip_v), (12.0, 18.0, 80.0, 40.0, 20.0, true)); +} +fn assert_comments(q: &Presentation) { let c = &q.slides[0].comments; assert_eq!(c.len(), 1); assert_eq!((c[0].author.as_str(), c[0].initials.as_str(), c[0].text.as_str()), ("Mina", "ML", "Check the quote")); assert_eq!(c[0].date, "2026-03-01T09:30:00.000"); assert_eq!((c[0].x, c[0].y), (36.0, 48.0)); +} +fn assert_click_links(q: &Presentation) { let click = |name: &str| shape(q, 0, name).click.clone().unwrap_or_else(|| panic!("{name} lost its click")); match click("Site").action { Action::Url { url } => assert_eq!(url, "https://example.com/deck"), @@ -259,7 +270,10 @@ fn assert_deck(q: &Presentation) { assert!(matches!(hover.action, Action::PreviousSlide)); assert!(hover.stop_sound); assert_eq!(hover.tooltip, "Back one"); +} +fn assert_custom_show_links(q: &Presentation) { + let click = |name: &str| shape(q, 0, name).click.clone().unwrap_or_else(|| panic!("{name} lost its click")); match click("To board").action { Action::CustomShow { name, returns } => { assert_eq!(name, "Board"); @@ -276,7 +290,9 @@ fn assert_deck(q: &Presentation) { } other => panic!("{other:?}"), } +} +fn assert_custom_shows(q: &Presentation) { assert_eq!(q.custom_shows.len(), 2); assert_eq!(q.custom_shows[0].name, "Short"); assert_eq!(q.custom_shows[0].slides, vec![q.slides[0].id]); diff --git a/crates/pptx/tests/it/corpus_media.rs b/crates/pptx/tests/it/corpus_media.rs index 6ffd14aa..ce0494d3 100644 --- a/crates/pptx/tests/it/corpus_media.rs +++ b/crates/pptx/tests/it/corpus_media.rs @@ -198,76 +198,92 @@ fn object_corpus_round_trips() { let twice = round(&once); for q in [&once, &twice] { - let diagram = by_name(q, "Diagram"); - let ShapeKind::Opaque { xml, label, .. } = &diagram.kind else { panic!("{:?}", diagram.kind) }; - assert_eq!(label, "SmartArt"); - assert!(xml.contains("Quarter"), "{xml}"); - assert!(!xml.contains("r:dm"), "diagram parts are not part of this corpus"); - - let embedded = by_name(q, "Embedded"); - let ShapeKind::Opaque { xml, label, .. } = &embedded.kind else { panic!("{:?}", embedded.kind) }; - assert_eq!(label, "DeckCraft.Note.1"); - assert!(xml.contains("DeckCraft.Note.1"), "{xml}"); - assert!(!xml.contains("r:id"), "an embedding part is issue #203"); - - let block_shape = by_name(q, "Block"); - assert!(matches!(block_shape.kind, ShapeKind::Model3d { poster: None, .. }), "{:?}", block_shape.kind); - assert_eq!(block_shape.xfrm, Some(Xfrm::new(40.0, 240.0, 120.0, 90.0))); - assert_eq!(media_bytes(q, "Block"), block); - let ShapeKind::Model3d { media, .. } = &block_shape.kind else { panic!("{:?}", block_shape.kind) }; - let block_media = q.media(*media).expect("block media"); - assert_eq!(block_media.content_type, "model/gltf-binary"); - assert!(block_media.name.to_ascii_lowercase().ends_with(".glb"), "{}", block_media.name); - - let globe_shape = by_name(q, "Globe"); - let ShapeKind::Model3d { media, poster: Some(poster_media) } = &globe_shape.kind else { panic!("{:?}", globe_shape.kind) }; - assert_eq!(globe_shape.xfrm, Some(Xfrm::new(180.0, 240.0, 160.0, 120.0))); - assert_eq!(q.media(*media).expect("globe").data.as_ref(), globe.as_slice()); - assert_eq!(q.media(*poster_media).expect("poster").data.as_ref(), poster.as_slice()); - - let narration = by_name(q, "Narration"); - let ShapeKind::Media(m) = &narration.kind else { panic!("{:?}", narration.kind) }; - assert!(!m.video); - assert_eq!(m.trim_start_ms, 400); - assert_eq!(m.trim_end_ms, 2600); - assert_eq!(m.fade_in_ms, 80); - assert_eq!(m.fade_out_ms, 120); - assert_eq!(m.bookmarks, vec![("Intro".into(), 400), ("Outro".into(), 2200)]); - assert_eq!(q.media(m.media).expect("audio").data.as_ref(), b"audio-bytes-deckcraft".as_slice()); - - let film = by_name(q, "Film"); - let ShapeKind::Media(m) = &film.kind else { panic!("{:?}", film.kind) }; - assert!(m.video); - assert_eq!(m.trim_start_ms, 250); - assert_eq!(m.trim_end_ms, 9000); - assert_eq!(m.bookmarks, vec![("Mark".into(), 1500)]); - assert_eq!(q.media(m.media).expect("video").data.as_ref(), b"video-bytes-deckcraft".as_slice()); - - assert_field(q, "Slide number", "slidenum", "4"); - assert_field(q, "Date", "datetime4", "14 October 2026"); - assert_eq!(q.first_slide_number, 4); - - assert_eq!(q.sections.iter().map(|s| s.name.as_str()).collect::>(), ["Open", "Aside"]); - let hidden = q.slides.iter().position(|s| s.hidden).expect("a hidden slide"); - assert_eq!(q.slides.iter().filter(|s| s.hidden).count(), 1); - assert!(q.sections[1].slides.contains(&q.slides[hidden].id)); - assert!(!q.sections[0].slides.contains(&q.slides[hidden].id)); - - assert_ink(by_name(q, "Sketch")); - - let timing = q.slides.iter().find(|s| s.animations.iter().any(|a| a.effect == "fade")).expect("timing slide"); - let fade = timing.animations.iter().find(|a| a.effect == "fade").expect("fade"); - let pulse = timing.animations.iter().find(|a| a.effect == "pulse").expect("pulse"); - assert_eq!(fade.repeat, 4); - assert!((fade.bounce_end - 0.25).abs() < 1e-9, "{}", fade.bounce_end); - assert_eq!(fade.trigger, Some(by_name(q, "Trigger").id)); - assert_eq!(fade.shape, by_name(q, "Fader").id); - assert_eq!(pulse.repeat, u32::MAX); - assert_eq!(pulse.class, AnimClass::Emphasis); - assert!(pulse.trigger.is_none()); + opaque_parts_survive(q); + models_survive(q, &block, &globe, &poster); + media_clips_survive(q); + fields_and_sections_survive(q); + ink_and_timing_survive(q); } } +fn opaque_parts_survive(q: &Presentation) { + let diagram = by_name(q, "Diagram"); + let ShapeKind::Opaque { xml, label, .. } = &diagram.kind else { panic!("{:?}", diagram.kind) }; + assert_eq!(label, "SmartArt"); + assert!(xml.contains("Quarter"), "{xml}"); + assert!(!xml.contains("r:dm"), "diagram parts are not part of this corpus"); + + let embedded = by_name(q, "Embedded"); + let ShapeKind::Opaque { xml, label, .. } = &embedded.kind else { panic!("{:?}", embedded.kind) }; + assert_eq!(label, "DeckCraft.Note.1"); + assert!(xml.contains("DeckCraft.Note.1"), "{xml}"); + assert!(!xml.contains("r:id"), "an embedding part is issue #203"); +} + +fn models_survive(q: &Presentation, block: &[u8], globe: &[u8], poster: &[u8]) { + let block_shape = by_name(q, "Block"); + assert!(matches!(block_shape.kind, ShapeKind::Model3d { poster: None, .. }), "{:?}", block_shape.kind); + assert_eq!(block_shape.xfrm, Some(Xfrm::new(40.0, 240.0, 120.0, 90.0))); + assert_eq!(media_bytes(q, "Block"), block); + let ShapeKind::Model3d { media, .. } = &block_shape.kind else { panic!("{:?}", block_shape.kind) }; + let block_media = q.media(*media).expect("block media"); + assert_eq!(block_media.content_type, "model/gltf-binary"); + assert!(block_media.name.to_ascii_lowercase().ends_with(".glb"), "{}", block_media.name); + + let globe_shape = by_name(q, "Globe"); + let ShapeKind::Model3d { media, poster: Some(poster_media) } = &globe_shape.kind else { panic!("{:?}", globe_shape.kind) }; + assert_eq!(globe_shape.xfrm, Some(Xfrm::new(180.0, 240.0, 160.0, 120.0))); + assert_eq!(q.media(*media).expect("globe").data.as_ref(), globe); + assert_eq!(q.media(*poster_media).expect("poster").data.as_ref(), poster); +} + +fn media_clips_survive(q: &Presentation) { + let narration = by_name(q, "Narration"); + let ShapeKind::Media(m) = &narration.kind else { panic!("{:?}", narration.kind) }; + assert!(!m.video); + assert_eq!(m.trim_start_ms, 400); + assert_eq!(m.trim_end_ms, 2600); + assert_eq!(m.fade_in_ms, 80); + assert_eq!(m.fade_out_ms, 120); + assert_eq!(m.bookmarks, vec![("Intro".into(), 400), ("Outro".into(), 2200)]); + assert_eq!(q.media(m.media).expect("audio").data.as_ref(), b"audio-bytes-deckcraft".as_slice()); + + let film = by_name(q, "Film"); + let ShapeKind::Media(m) = &film.kind else { panic!("{:?}", film.kind) }; + assert!(m.video); + assert_eq!(m.trim_start_ms, 250); + assert_eq!(m.trim_end_ms, 9000); + assert_eq!(m.bookmarks, vec![("Mark".into(), 1500)]); + assert_eq!(q.media(m.media).expect("video").data.as_ref(), b"video-bytes-deckcraft".as_slice()); +} + +fn fields_and_sections_survive(q: &Presentation) { + assert_field(q, "Slide number", "slidenum", "4"); + assert_field(q, "Date", "datetime4", "14 October 2026"); + assert_eq!(q.first_slide_number, 4); + + assert_eq!(q.sections.iter().map(|s| s.name.as_str()).collect::>(), ["Open", "Aside"]); + let hidden = q.slides.iter().position(|s| s.hidden).expect("a hidden slide"); + assert_eq!(q.slides.iter().filter(|s| s.hidden).count(), 1); + assert!(q.sections[1].slides.contains(&q.slides[hidden].id)); + assert!(!q.sections[0].slides.contains(&q.slides[hidden].id)); +} + +fn ink_and_timing_survive(q: &Presentation) { + assert_ink(by_name(q, "Sketch")); + + let timing = q.slides.iter().find(|s| s.animations.iter().any(|a| a.effect == "fade")).expect("timing slide"); + let fade = timing.animations.iter().find(|a| a.effect == "fade").expect("fade"); + let pulse = timing.animations.iter().find(|a| a.effect == "pulse").expect("pulse"); + assert_eq!(fade.repeat, 4); + assert!((fade.bounce_end - 0.25).abs() < 1e-9, "{}", fade.bounce_end); + assert_eq!(fade.trigger, Some(by_name(q, "Trigger").id)); + assert_eq!(fade.shape, by_name(q, "Fader").id); + assert_eq!(pulse.repeat, u32::MAX); + assert_eq!(pulse.class, AnimClass::Emphasis); + assert!(pulse.trigger.is_none()); +} + /// An exported deck with `parts` added (or replaced) and `edit` applied to every XML part. fn with_parts(parts: &[(&str, Vec)], edit: impl Fn(&str, String) -> String) -> Vec { let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export"); diff --git a/crates/pptx/tests/it/corpus_style.rs b/crates/pptx/tests/it/corpus_style.rs index d552002f..6eac7bdd 100644 --- a/crates/pptx/tests/it/corpus_style.rs +++ b/crates/pptx/tests/it/corpus_style.rs @@ -189,9 +189,14 @@ fn style_kinds_round_trip() { .as_slice() ) ); + text_run_effects_survive(&p, &once, &twice); + theme_and_bevel_survive(&once, &twice); + connector_glue_survives(&p, &once, &twice); +} - let got = run_of(&once); - let expect = run_of(&p); +fn text_run_effects_survive(p: &Presentation, once: &Presentation, twice: &Presentation) { + let got = run_of(once); + let expect = run_of(p); assert_eq!(got.fill, expect.fill, "text gradient"); assert_eq!(got.outline, expect.outline, "text outline"); assert_eq!(got.shadow, expect.shadow, "text shadow"); @@ -199,17 +204,19 @@ fn style_kinds_round_trip() { assert_eq!(got.inner_shadow, expect.inner_shadow, "text inner shadow"); assert_eq!(got.reflection, expect.reflection, "text reflection"); assert_eq!(got.soft_edge, expect.soft_edge, "text soft edge"); - assert_eq!(run_of(&twice), run_of(&once)); + assert_eq!(run_of(twice), run_of(once)); assert_eq!( - by_name(&twice, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)), - by_name(&once, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)) + by_name(twice, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)), + by_name(once, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)) ); - assert_eq!(by_name(&once, "Path gradient").fill, by_name(&p, "Path gradient").fill); - assert_eq!(by_name(&once, "Pattern").fill, by_name(&p, "Pattern").fill); - assert_eq!(by_name(&twice, "Path gradient").fill, by_name(&once, "Path gradient").fill); - assert_eq!(by_name(&twice, "Pattern").fill, by_name(&once, "Pattern").fill); + assert_eq!(by_name(once, "Path gradient").fill, by_name(p, "Path gradient").fill); + assert_eq!(by_name(once, "Pattern").fill, by_name(p, "Pattern").fill); + assert_eq!(by_name(twice, "Path gradient").fill, by_name(once, "Path gradient").fill); + assert_eq!(by_name(twice, "Pattern").fill, by_name(once, "Pattern").fill); +} +fn theme_and_bevel_survive(once: &Presentation, twice: &Presentation) { let theme = &once.masters[0].theme.format.effects[0]; assert_eq!(theme.glow, Some(glow())); assert_eq!(theme.reflection, Some(reflection())); @@ -220,26 +227,24 @@ fn style_kinds_round_trip() { assert_eq!(theme2.bevel, theme.bevel); assert_eq!(theme2.raw3d, theme.raw3d); - let named = by_name(&once, "Bevel preset").effects.as_ref().expect("effects"); + let named = by_name(once, "Bevel preset").effects.as_ref().expect("effects"); assert_eq!(named.bevel.as_deref(), Some("relaxedInset")); assert!(named.raw3d.as_deref().unwrap_or("").contains("relaxedInset")); - assert_eq!(by_name(&twice, "Bevel preset").effects.as_ref().and_then(|e| e.bevel.as_deref()), Some("relaxedInset")); + assert_eq!(by_name(twice, "Bevel preset").effects.as_ref().and_then(|e| e.bevel.as_deref()), Some("relaxedInset")); - let bevel = by_name(&once, "Bevel").effects.as_ref().expect("effects"); + let bevel = by_name(once, "Bevel").effects.as_ref().expect("effects"); assert_eq!(bevel.bevel.as_deref(), Some("angle")); let raw = bevel.raw3d.as_deref().unwrap_or(""); assert!(raw.contains("bevelT") && raw.contains("angle") && raw.contains("scene3d"), "{raw}"); - assert_eq!(by_name(&twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw)); + assert_eq!(by_name(twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw)); +} - 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 - ); - assert_eq!(c.line, by_name(&p, "Connector").line); - assert_eq!(c.geom, by_name(&p, "Connector").geom); - assert_eq!(by_name(&twice, "Connector").kind, c.kind); +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); + assert_eq!(c.line, by_name(p, "Connector").line); + assert_eq!(c.geom, by_name(p, "Connector").geom); + assert_eq!(by_name(twice, "Connector").kind, c.kind); } fn left_of(p: &Presentation) -> ShapeId { diff --git a/crates/pptx/tests/it/corpus_themes.rs b/crates/pptx/tests/it/corpus_themes.rs index 15f76ae0..744b27ba 100644 --- a/crates/pptx/tests/it/corpus_themes.rs +++ b/crates/pptx/tests/it/corpus_themes.rs @@ -148,10 +148,13 @@ fn effects_and_links_survive_a_round_trip() { assert!(matches!(&links[1].1, Some(Hyperlink { action: Action::Url { url }, .. }) if url.starts_with("mailto:"))); let hover = get("Reflected").text.as_ref().and_then(|t| t.paragraphs[0].runs[0].props.hover.clone()); assert!(matches!(hover, Some(Hyperlink { action: Action::Url { .. }, ref tooltip, .. }) if tooltip == "Preview"), "{hover:?}"); + effects_and_links_survive_resave(&a); +} +fn effects_and_links_survive_resave(a: &Presentation) { // Saved and reopened: the same effects and links. - let b = round_trip(&a); - let (ga, gb) = (shapes_named(&a, 0), shapes_named(&b, 0)); + let b = round_trip(a); + let (ga, gb) = (shapes_named(a, 0), shapes_named(&b, 0)); for n in ["Shadowed", "Glowing", "Soft edged", "Reflected"] { assert_eq!(fx(ga(n)), fx(gb(n)), "effects of {n}"); assert_eq!(ga(n).click, gb(n).click, "click of {n}"); @@ -203,24 +206,28 @@ fn two_masters() -> Presentation { p } +fn same_master(ma: &deckcraft_model::Master, mb: &deckcraft_model::Master) { + assert_eq!(ma.name, mb.name); + assert_eq!(ma.theme.name, mb.theme.name); + // The writer adds PowerPoint's empty objectDefaults/extraClrSchemeLst, kept as raw XML. + let bare = |t: &deckcraft_model::theme::Theme| deckcraft_model::theme::Theme { raw_extra: None, ..t.clone() }; + assert_eq!(bare(&ma.theme), bare(&mb.theme), "whole theme of {}", ma.name); + assert_eq!(ma.theme.colors, mb.theme.colors, "colour scheme of {}", ma.name); + assert_eq!(ma.theme.fonts.name, mb.theme.fonts.name); + assert_eq!(ma.theme.fonts.major.latin, mb.theme.fonts.major.latin); + assert_eq!(ma.theme.fonts.minor.latin, mb.theme.fonts.minor.latin); + assert_eq!(ma.background, mb.background, "background of {}", ma.name); + let names = |m: &deckcraft_model::Master| m.layouts.iter().map(|l| l.name.clone()).collect::>(); + assert_eq!(names(ma), names(mb)); +} + #[test] fn themes_masters_and_layouts_survive_a_round_trip() { let a = two_masters(); let b = round_trip(&a); assert_eq!(b.masters.len(), 2); for (ma, mb) in a.masters.iter().zip(&b.masters) { - assert_eq!(ma.name, mb.name); - assert_eq!(ma.theme.name, mb.theme.name); - // The writer adds PowerPoint's empty objectDefaults/extraClrSchemeLst, kept as raw XML. - let bare = |t: &deckcraft_model::theme::Theme| deckcraft_model::theme::Theme { raw_extra: None, ..t.clone() }; - assert_eq!(bare(&ma.theme), bare(&mb.theme), "whole theme of {}", ma.name); - assert_eq!(ma.theme.colors, mb.theme.colors, "colour scheme of {}", ma.name); - assert_eq!(ma.theme.fonts.name, mb.theme.fonts.name); - assert_eq!(ma.theme.fonts.major.latin, mb.theme.fonts.major.latin); - assert_eq!(ma.theme.fonts.minor.latin, mb.theme.fonts.minor.latin); - assert_eq!(ma.background, mb.background, "background of {}", ma.name); - let names = |m: &deckcraft_model::Master| m.layouts.iter().map(|l| l.name.clone()).collect::>(); - assert_eq!(names(ma), names(mb)); + same_master(ma, mb); } // Each slide stays on its master's layout. let layout_name = diff --git a/crates/pptx/tests/it/sample.rs b/crates/pptx/tests/it/sample.rs index f42ca230..f7cbba12 100644 --- a/crates/pptx/tests/it/sample.rs +++ b/crates/pptx/tests/it/sample.rs @@ -10,6 +10,30 @@ fn sample() -> Presentation { s.doc().expect("doc").doc.as_ref().clone() } +fn same_shapes(a: &deckcraft_model::Slide, b: &deckcraft_model::Slide) { + assert_eq!(a.shapes.len(), b.shapes.len(), "shape count on slide '{}'", a.title()); + for (sa, sb) in a.shapes.iter().zip(b.shapes.iter()) { + assert_eq!(sa.kind_name(), sb.kind_name(), "{}", sa.name); + assert_eq!(sa.text.as_ref().map(|t| t.text()), sb.text.as_ref().map(|t| t.text()), "{}", sa.name); + if let (Some(x), Some(y)) = (sa.xfrm, sb.xfrm) { + assert!((x.x - y.x).abs() < 0.01 && (x.w - y.w).abs() < 0.01, "{}: {x:?} vs {y:?}", sa.name); + } + if let (ShapeKind::Table(ta), ShapeKind::Table(tb)) = (&sa.kind, &sb.kind) { + assert_eq!(ta.style, tb.style); + assert_eq!(ta.rows.len(), tb.rows.len()); + } + if let (ShapeKind::Chart(ca), ShapeKind::Chart(cb)) = (&sa.kind, &sb.kind) { + assert_eq!(ca.kind, cb.kind); + assert_eq!(ca.categories, cb.categories); + assert_eq!(ca.series.len(), cb.series.len()); + for (x, y) in ca.series.iter().zip(cb.series.iter()) { + assert_eq!(x.name, y.name); + assert_eq!(x.values, y.values); + } + } + } +} + #[test] fn sample_deck_round_trips() { let p = sample(); @@ -25,27 +49,7 @@ fn sample_deck_round_trips() { for (a, b) in p.slides.iter().zip(q.slides.iter()) { assert_eq!(a.title(), b.title()); assert_eq!(a.notes_text(), b.notes_text()); - assert_eq!(a.shapes.len(), b.shapes.len(), "shape count on slide '{}'", a.title()); - for (sa, sb) in a.shapes.iter().zip(b.shapes.iter()) { - assert_eq!(sa.kind_name(), sb.kind_name(), "{}", sa.name); - assert_eq!(sa.text.as_ref().map(|t| t.text()), sb.text.as_ref().map(|t| t.text()), "{}", sa.name); - if let (Some(x), Some(y)) = (sa.xfrm, sb.xfrm) { - assert!((x.x - y.x).abs() < 0.01 && (x.w - y.w).abs() < 0.01, "{}: {x:?} vs {y:?}", sa.name); - } - if let (ShapeKind::Table(ta), ShapeKind::Table(tb)) = (&sa.kind, &sb.kind) { - assert_eq!(ta.style, tb.style); - assert_eq!(ta.rows.len(), tb.rows.len()); - } - if let (ShapeKind::Chart(ca), ShapeKind::Chart(cb)) = (&sa.kind, &sb.kind) { - assert_eq!(ca.kind, cb.kind); - assert_eq!(ca.categories, cb.categories); - assert_eq!(ca.series.len(), cb.series.len()); - for (x, y) in ca.series.iter().zip(cb.series.iter()) { - assert_eq!(x.name, y.name); - assert_eq!(x.values, y.values); - } - } - } + same_shapes(a, b); assert_eq!(a.transition.as_ref().map(|t| (&t.kind, &t.option)), b.transition.as_ref().map(|t| (&t.kind, &t.option))); assert_eq!(a.animations.len(), b.animations.len(), "animations on '{}'", a.title()); for (x, y) in a.animations.iter().zip(b.animations.iter()) { diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 626c2d91..cb96dba9 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -147,18 +147,16 @@ fn split_label(name: &str) -> String { } } -pub(crate) fn label( - ctx: &mut RenderContext, - rctx: &Ctx, - m: Affine, - text: &str, - r: Rect, - size: f64, - align: Align, - anchor: Anchor, - col: Rgba, - bold: bool, -) { +pub(crate) struct LabelStyle { + pub(crate) size: f64, + pub(crate) align: Align, + pub(crate) anchor: Anchor, + pub(crate) col: Rgba, + pub(crate) bold: bool, +} + +pub(crate) fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, style: LabelStyle) { + let LabelStyle { size, align, anchor, col, bold } = style; let mut body = TextBody::from_text(text); body.body.inset_l = Some(0.0); body.body.inset_r = Some(0.0); @@ -178,7 +176,14 @@ pub(crate) fn label( } } let tmp = Shape { text: Some(body.clone()), ..Default::default() }; - crate::draw_text(ctx, rctx, &tmp, &body, r, m, &deckcraft_text::NoFields, None); + crate::draw_text( + ctx, + rctx, + &tmp, + &body, + m, + &deckcraft_text::Opts { rect: r, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); } fn fmt_num(v: f64) -> String { @@ -246,15 +251,24 @@ fn axis_ticks((lo, hi, st): (f64, f64, f64)) -> impl Iterator { pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { match c.kind { // Histograms and Pareto charts are drawn as the column (and line) chart of their bins. - ChartType::Histogram | ChartType::Pareto => draw_inner(ctx, rctx, &c.histogram_view(), m, w, h, None), + ChartType::Histogram | ChartType::Pareto => { + draw_inner(ctx, rctx, &c.histogram_view(), ChartFrame { transform: m, width: w, height: h, boxes: None }) + } ChartType::BoxWhisker => { let (view, boxes) = crate::chartex::box_view(c); - draw_inner(ctx, rctx, &view, m, w, h, Some(&boxes)); + draw_inner(ctx, rctx, &view, ChartFrame { transform: m, width: w, height: h, boxes: Some(&boxes) }); } - _ => draw_inner(ctx, rctx, c, m, w, h, None), + _ => draw_inner(ctx, rctx, c, ChartFrame { transform: m, width: w, height: h, boxes: None }), } } +struct ChartFrame<'a> { + transform: Affine, + width: f64, + height: f64, + boxes: Option<&'a crate::chartex::Boxes>, +} + /// PowerPoint's default chart text colour: `tx1` with `lumMod` 65000 and `lumOff` 35000, which /// is 595959 on a light theme and a light grey on a dark one. pub fn default_text_color() -> ColorRef { @@ -262,8 +276,8 @@ pub fn default_text_color() -> ColorRef { ColorRef::scheme(SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000)) } -#[allow(clippy::too_many_arguments)] -fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { +fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, frame: ChartFrame<'_>) { + let ChartFrame { transform: m, width: w, height: h, boxes } = frame; // Text without a colour of its own takes PowerPoint's default, the theme's text colour at // lumMod 65 % / lumOff 35 % (595959 on a light theme, light grey on a dark one), so a chart // on a dark theme is readable (a fixed grey used to vanish there). @@ -289,128 +303,24 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, let title_size = c.title_size.unwrap_or(base * 1.4); let mut area = Rect::new(w * 0.04, h * 0.04, w * 0.96, h * 0.96); if let Some(t) = &c.title { - label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), title_size, Align::Center, Anchor::Top, text_col, false); + label( + ctx, + rctx, + m, + t, + Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), + LabelStyle { size: title_size, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, + ); area.y0 += title_size * 1.7; } let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); // Legend placed by `c:legendPos`. let waterfall = c.kind == ChartType::Waterfall; // Each legend entry: its name, its key's paint and the key's outline. - let legend_items: Vec<(String, Paint, Option<(Rgba, f64)>)> = if waterfall { - ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { - // A hierarchy's legend names its top-level groups. - c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) { - // A map draws its own colour scale. - vec![] - } else if pie { - c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else { - c.series - .iter() - .enumerate() - .map(|(i, s)| { - let k = c.series_kind(s); - let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); - if line { - (s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None) - } else if k.is_bar_like() { - let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() { - Paint::Color(series_color(rctx, c, i)) - } else { - paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i)) - }; - (s.name.clone(), paint, bar_outline(rctx, s)) - } else { - (s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None) - } - }) - .collect() - }; - if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) { - let vertical = matches!(pos.as_str(), "l" | "r" | "tr"); - let item_ws: Vec = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); - let size = if vertical { - (item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5) - } else { - (item_ws.iter().sum::().min(w * 0.9), base * 1.4) - }; - let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay); - area = plot_area; - let (mut x, mut y) = (origin.x, origin.y); - for (name, paint, outline) in &legend_items { - let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base); - fill_with(ctx, paint, key, m); - if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) { - ctx.set_transform(m); - ctx.set_paint(color(oc, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2))); - ctx.stroke_path(&key.to_path(0.1)); - } - let tw = name.chars().count() as f64 * base * 0.55 + base; - label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false); - if vertical { - y += base * 1.5; - } else { - x += tw + base * 0.6; - } - } - } + let legend_items = build_legend_items(rctx, c, pie, waterfall); + area = draw_legend(ctx, &LegendDraw { rctx, chart: c, transform: m, items: &legend_items, base, text_color: text_col, width: w }, area); if pie { - let Some(s) = c.series.first() else { return }; - let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); - let total: f64 = vals.iter().sum(); - if total <= 0.0 { - return; - } - let r = (area.width().min(area.height()) / 2.0) * 0.95; - let cen = area.center(); - let mut a0 = -std::f64::consts::FRAC_PI_2; - for (i, v) in vals.iter().enumerate() { - let sweep = v / total * std::f64::consts::TAU; - let mut p = BezPath::new(); - p.move_to(cen); - let arc = kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0); - p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); - p.extend(arc.append_iter(0.1)); - p.close_path(); - ctx.set_transform(m); - let col = s - .point_fills - .iter() - .find(|(k, _)| *k == i) - .and_then(|(_, f)| if let Fill::Solid { color: cc } = f { Some(rctx.color(cc, None)) } else { None }) - .unwrap_or_else(|| series_color(rctx, c, i)); - ctx.set_paint(color(col, 1.0)); - ctx.fill_path(&p); - ctx.set_paint(color(Rgba::WHITE, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(1.0)); - ctx.stroke_path(&p); - if c.series_data_labels(s) { - let mid = a0 + sweep / 2.0; - let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin()); - label( - ctx, - rctx, - m, - &c.data_label(s, *v), - Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0), - base, - Align::Center, - Anchor::Middle, - // On the slices themselves: white, unless the chart's text was given a colour. - if c.text_color.is_some() { text_col } else { Rgba::WHITE }, - false, - ); - } - a0 += sweep; - } - if c.kind == ChartType::Doughnut { - ctx.set_transform(m); - ctx.set_paint(color(Rgba::WHITE, 1.0)); - ctx.fill_path(&kurbo::Circle::new(cen, r * c.hole_size.clamp(0.1, 0.9)).to_path(0.1)); - } + draw_pie(ctx, rctx, c, m, area, base, text_col); return; } if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) { @@ -425,23 +335,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, // Bars are drawn first and lines over them; a combo chart has both. let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like()); let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() }; - // Axis titles: the left one turned to read upwards, the bottom one centred under the axis. - // On a horizontal bar chart the category axis is the left one. - let (left_title, bottom_title) = - if horiz { (c.axis_titles.0.as_deref(), c.axis_titles.1.as_deref()) } else { (c.axis_titles.1.as_deref(), c.axis_titles.0.as_deref()) }; - let band_h = base * 1.6; - if let Some(t) = bottom_title.filter(|t| !t.is_empty()) { - let r = Rect::new(area.x0, area.y1 - band_h, area.x1, area.y1); - label(ctx, rctx, m, t, r, base, Align::Center, Anchor::Bottom, text_col, true); - area.y1 -= band_h + base * 0.3; - } - if let Some(t) = left_title.filter(|t| !t.is_empty()) { - let (cx, cy) = (area.x0 + band_h / 2.0, (area.y0 + area.y1) / 2.0); - let turned = m * Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2); - let half = area.height() / 2.0; - label(ctx, rctx, turned, t, Rect::new(-half, -band_h / 2.0, half, band_h / 2.0), base, Align::Center, Anchor::Middle, text_col, true); - area.x0 += band_h + base * 0.3; - } + area = draw_axis_titles(ctx, rctx, &AxisTitleDrawing { chart: c, transform: m, base, text_color: text_col, horiz }, area); // As many major units as the value axis has room for labels (PowerPoint's automatic unit). let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 }; let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize; @@ -492,6 +386,265 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, (true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / positive_span(max2 - min2) * plot.height(), _ => to_v(v), }; + draw_gridlines( + ctx, + &GridlineDrawing { + rctx, + chart: c, + transform: m, + area, + plot, + horiz, + base, + text_color: text_col, + grid_color: grid_col, + min, + max, + step, + log, + primary_ticks: &primary_ticks, + second, + to_v: &to_v, + to_axis: &to_axis, + }, + ); + let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); + // Forecast reserves empty category slots so the line can run past the data. Charts with no + // forecast keep the old band. + let (back, fwd) = forecast_pad(c); + let nspan = ncat as f64 + back + fwd; + let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; + // Where category `i`'s band starts along its axis: left to right, or bottom to top on a + // horizontal bar chart, unless the axis is reversed. The forecast's empty slots stay on + // their own side of the data. + let backwards = horiz != c.cat_reversed; + let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back }; + // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its + // own tick labels in place of category names. + let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; + let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width()); + draw_category_labels( + ctx, + &CategoryLabelDrawing { rctx, chart: c, transform: m, area, plot, horiz, band, slot: &slot, xscale, base, text_color: text_col }, + ); + if let Some(b) = boxes { + crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); + return; + } + let zero = match log { + Some((b, _)) => to_v(b.powf(min)), + None => to_v(clamp_soft(0.0, min, max)), + }; + // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). + let mut err_marks = draw_bars( + ctx, + &BarDrawing { + rctx, + chart: c, + transform: m, + plot, + band, + horiz, + ncat, + bar_kind, + waterfall, + slot: &slot, + to_v: &to_v, + to_axis: &to_axis, + base, + text_color: text_col, + }, + ); + // A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series. + if c.kind == ChartType::Stock { + for i in 0..ncat { + crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v)); + } + } + draw_lines( + ctx, + &mut LineDrawing { + chart: c, + rctx, + transform: m, + plot, + category_count: ncat, + band, + slot: &slot, + to_x: &to_x, + to_axis: &to_axis, + zero, + base, + error_marks: &mut err_marks, + }, + ); + // A category position (0-based, fractional) as a slot, the way `slot` places the bars. + let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; + draw_trendlines(ctx, rctx, c, m, TrendlineLayout { plot, band, slot: &slot_at, xscale, ncat, horiz, base, text_col, to_axis: &to_axis }); + if !err_marks.is_empty() { + let mut p = BezPath::new(); + for &(mid, a, b, cap, end_cap) in &err_marks { + // A horizontal bar chart's error bars run across, its value axis being horizontal. + let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; + p.move_to(pt(a, mid)); + p.line_to(pt(b, mid)); + if end_cap { + for e in [a, b] { + p.move_to(pt(e, mid - cap)); + p.line_to(pt(e, mid + cap)); + } + } + } + ctx.set_transform(m); + ctx.set_paint(color(text_col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&p); + } + // Axis line. + let mut ax = BezPath::new(); + if horiz { + ax.move_to((zero, plot.y0)); + ax.line_to((zero, plot.y1)); + } else { + ax.move_to((plot.x0, zero)); + ax.line_to((plot.x1, zero)); + } + ctx.set_transform(m); + ctx.set_paint(color(Rgba::rgb(0xBF, 0xBF, 0xBF), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&ax); +} + +struct LegendDraw<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + items: &'a [(String, Paint, Option<(Rgba, f64)>)], + base: f64, + text_color: Rgba, + width: f64, +} + +fn draw_legend(ctx: &mut RenderContext, draw: &LegendDraw<'_, '_>, mut area: Rect) -> Rect { + let rctx = draw.rctx; + let c = draw.chart; + let m = draw.transform; + let legend_items = draw.items; + let base = draw.base; + let text_col = draw.text_color; + let width = draw.width; + let Some(pos) = c.legend.as_ref().filter(|_| !legend_items.is_empty()) else { return area }; + let vertical = matches!(pos.as_str(), "l" | "r" | "tr"); + let item_ws: Vec = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); + let size = if vertical { + (item_ws.iter().copied().fold(0.0, f64::max).min(width * 0.4), legend_items.len() as f64 * base * 1.5) + } else { + (item_ws.iter().sum::().min(width * 0.9), base * 1.4) + }; + let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay); + area = plot_area; + let (mut x, mut y) = (origin.x, origin.y); + for (name, paint, outline) in legend_items { + let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base); + fill_with(ctx, paint, key, m); + if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) { + ctx.set_transform(m); + ctx.set_paint(color(oc, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2))); + ctx.stroke_path(&key.to_path(0.1)); + } + let tw = name.chars().count() as f64 * base * 0.55 + base; + label( + ctx, + rctx, + m, + name, + Rect::new(x + base, y, x + base + tw, y + base * 1.3), + LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false }, + ); + if vertical { + y += base * 1.5; + } else { + x += tw + base * 0.6; + } + } + area +} + +fn build_legend_items(rctx: &Ctx, c: &Chart, pie: bool, waterfall: bool) -> Vec<(String, Paint, Option<(Rgba, f64)>)> { + if waterfall { + ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { + // A hierarchy's legend names its top-level groups. + c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) { + // A map draws its own colour scale. + vec![] + } else if pie { + c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else { + c.series + .iter() + .enumerate() + .map(|(i, s)| { + let k = c.series_kind(s); + let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); + if line { + (s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None) + } else if k.is_bar_like() { + let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() { + Paint::Color(series_color(rctx, c, i)) + } else { + paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i)) + }; + (s.name.clone(), paint, bar_outline(rctx, s)) + } else { + (s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None) + } + }) + .collect() + } +} + +struct GridlineDrawing<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + area: Rect, + plot: Rect, + horiz: bool, + base: f64, + text_color: Rgba, + grid_color: Rgba, + min: f64, + max: f64, + step: f64, + log: Option<(f64, (f64, f64, f64))>, + primary_ticks: &'a [f64], + second: Option<(f64, f64, f64)>, + to_v: &'a dyn Fn(f64) -> f64, + to_axis: &'a dyn Fn(bool, f64) -> f64, +} + +fn draw_gridlines(ctx: &mut RenderContext, draw: &GridlineDrawing<'_, '_>) { + let rctx = draw.rctx; + let c = draw.chart; + let m = draw.transform; + let area = draw.area; + let plot = draw.plot; + let horiz = draw.horiz; + let base = draw.base; + let text_col = draw.text_color; + let grid_col = draw.grid_color; + let min = draw.min; + let max = draw.max; + let step = draw.step; + let log = draw.log; + let primary_ticks = draw.primary_ticks; + let second = draw.second; + let to_v = draw.to_v; + let to_axis = draw.to_axis; + let ticks = axis_ticks; // Minor gridlines, under the major ones: five to a major unit. if c.minor_gridlines { let minor = step / 5.0; @@ -515,7 +668,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, ctx.stroke_path(&g); } // Gridlines and value labels. - for &v in &primary_ticks { + for &v in primary_ticks { let pos = to_v(v); let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) }; let mut g = BezPath::new(); @@ -528,11 +681,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &text, Rect::new(pos - 30.0, plot.y1 + 2.0, pos + 30.0, plot.y1 + base * 1.5), - base, - Align::Center, - Anchor::Top, - text_col, - false, + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, ); } else { g.move_to((plot.x0, pos)); @@ -543,11 +692,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &text, Rect::new(area.x0, pos - base, plot.x0 - base * 0.4, pos + base), - base, - Align::Right, - Anchor::Middle, - text_col, - false, + LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, ); } if c.gridlines { @@ -567,29 +712,39 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &c.axis_label(true, v), Rect::new(plot.x1 + base * 0.4, pos - base, area.x1, pos + base), - base, - Align::Left, - Anchor::Middle, - text_col, - false, + LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false }, ); } } - let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); - // Forecast reserves empty category slots so the line can run past the data. Charts with no - // forecast keep the old band. - let (back, fwd) = forecast_pad(c); - let nspan = ncat as f64 + back + fwd; - let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; - // Where category `i`'s band starts along its axis: left to right, or bottom to top on a - // horizontal bar chart, unless the axis is reversed. The forecast's empty slots stay on - // their own side of the data. - let backwards = horiz != c.cat_reversed; - let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back }; - // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its - // own tick labels in place of category names. - let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; - let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width()); +} + +struct CategoryLabelDrawing<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + area: Rect, + plot: Rect, + horiz: bool, + band: f64, + slot: &'a dyn Fn(usize) -> f64, + xscale: Option<(f64, f64, f64)>, + base: f64, + text_color: Rgba, +} + +fn draw_category_labels(ctx: &mut RenderContext, draw: &CategoryLabelDrawing<'_, '_>) { + let rctx = draw.rctx; + let c = draw.chart; + let m = draw.transform; + let area = draw.area; + let plot = draw.plot; + let horiz = draw.horiz; + let band = draw.band; + let slot = draw.slot; + let xscale = draw.xscale; + let base = draw.base; + let text_col = draw.text_color; + let ticks = axis_ticks; if let Some(sc) = xscale { for v in ticks(sc) { let pos = plot.x0 + (v - sc.0) / positive_span(sc.1 - sc.0) * plot.width(); @@ -599,11 +754,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &fmt_num(v), Rect::new(pos - 30.0, plot.y1 + 3.0, pos + 30.0, plot.y1 + base * 1.6), - base, - Align::Center, - Anchor::Top, - text_col, - false, + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, ); } } @@ -618,11 +769,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, name, Rect::new(area.x0, mid - base, plot.x0 - base * 0.4, mid + base), - base, - Align::Right, - Anchor::Middle, - text_col, - false, + LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, ); } else { let text = if wraps { split_label(name) } else { name.clone() }; @@ -632,23 +779,181 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &text, Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * if wraps { 2.8 } else { 1.6 }), - base, - Align::Center, - Anchor::Top, - text_col, - false, + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, ); } } - if let Some(b) = boxes { - crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); - return; +} + +struct AxisTitleDrawing<'a> { + chart: &'a Chart, + transform: Affine, + base: f64, + text_color: Rgba, + horiz: bool, +} + +fn draw_axis_titles(ctx: &mut RenderContext, rctx: &Ctx, draw: &AxisTitleDrawing<'_>, mut area: Rect) -> Rect { + let c = draw.chart; + let m = draw.transform; + let base = draw.base; + let text_col = draw.text_color; + let horiz = draw.horiz; + // Axis titles: the left one turned to read upwards, the bottom one centred under the axis. + // On a horizontal bar chart the category axis is the left one. + let (left_title, bottom_title) = + if horiz { (c.axis_titles.0.as_deref(), c.axis_titles.1.as_deref()) } else { (c.axis_titles.1.as_deref(), c.axis_titles.0.as_deref()) }; + let band_h = base * 1.6; + if let Some(t) = bottom_title.filter(|t| !t.is_empty()) { + let r = Rect::new(area.x0, area.y1 - band_h, area.x1, area.y1); + label(ctx, rctx, m, t, r, LabelStyle { size: base, align: Align::Center, anchor: Anchor::Bottom, col: text_col, bold: true }); + area.y1 -= band_h + base * 0.3; } - let zero = match log { - Some((b, _)) => to_v(b.powf(min)), - None => to_v(clamp_soft(0.0, min, max)), - }; - // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). + if let Some(t) = left_title.filter(|t| !t.is_empty()) { + let (cx, cy) = (area.x0 + band_h / 2.0, (area.y0 + area.y1) / 2.0); + let turned = m * Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2); + let half = area.height() / 2.0; + label( + ctx, + rctx, + turned, + t, + Rect::new(-half, -band_h / 2.0, half, band_h / 2.0), + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Middle, col: text_col, bold: true }, + ); + area.x0 += band_h + base * 0.3; + } + area +} + +struct BarDrawing<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + plot: Rect, + band: f64, + horiz: bool, + ncat: usize, + bar_kind: Option, + waterfall: bool, + slot: &'a dyn Fn(usize) -> f64, + to_v: &'a dyn Fn(f64) -> f64, + to_axis: &'a dyn Fn(bool, f64) -> f64, + base: f64, + text_color: Rgba, +} + +struct LineDrawing<'a, 'ctx> { + chart: &'a Chart, + rctx: &'a Ctx<'ctx>, + transform: Affine, + plot: Rect, + category_count: usize, + band: f64, + slot: &'a dyn Fn(usize) -> f64, + to_x: &'a dyn Fn(f64) -> Option, + to_axis: &'a dyn Fn(bool, f64) -> f64, + zero: f64, + base: f64, + error_marks: &'a mut Vec<(f64, f64, f64, f64, bool)>, +} + +fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) { + let c = draw.chart; + let rctx = draw.rctx; + let m = draw.transform; + let plot = draw.plot; + let ncat = draw.category_count; + let band = draw.band; + let slot = draw.slot; + let to_x = draw.to_x; + let to_axis = draw.to_axis; + let zero = draw.zero; + let base = draw.base; + let err_marks = &mut draw.error_marks; + let mut acc = vec![0.0; ncat]; + for (si, s) in c.series.iter().enumerate() { + let kind = c.series_kind(s); + if kind.is_bar_like() || kind == ChartType::Stock { + continue; + } + let sec = c.on_secondary(s); + let stacked = kind.stacked(); + let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); + let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; + let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values)); + let mut pts = vec![]; + for i in 0..ncat { + let Some(v) = s.values.get(i).copied().flatten() else { continue }; + let v = if stacked { + let base_v = acc.get(i).copied().unwrap_or(0.0); + if let Some(a) = acc.get_mut(i) { + *a += v; + } + base_v + v + } else { + v + }; + let x = match kind { + ChartType::Scatter | ChartType::Bubble => { + match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { + (Some(x), _) => x, + // No x values at all: the points are numbered 1, 2, 3... + (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), + (None, Some(_)) => continue, + } + } + _ => plot.x0 + band * (slot(i) + 0.5), + }; + if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| series_stats.as_ref().and_then(|st| e.range_in(st, i, v))) { + err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); + } + pts.push(Point::new(x, to_axis(sec, v))); + } + if pts.is_empty() { + continue; + } + let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) }; + ctx.set_transform(m); + if area_kind { + let mut a = p.clone(); + if let (Some(l), Some(f)) = (pts.last(), pts.first()) { + a.line_to((l.x, zero)); + a.line_to((f.x, zero)); + a.close_path(); + } + ctx.set_paint(color(col, 0.85)); + ctx.fill_path(&a); + } else if kind != ChartType::Scatter { + let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&p); + } + if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) { + ctx.set_paint(color(col, 1.0)); + for q in &pts { + ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); + } + } + } +} + +fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f64, f64, f64, bool)> { + let c = draw.chart; + let rctx = draw.rctx; + let m = draw.transform; + let plot = draw.plot; + let band = draw.band; + let horiz = draw.horiz; + let ncat = draw.ncat; + let bar_kind = draw.bar_kind; + let waterfall = draw.waterfall; + let slot = draw.slot; + let to_v = draw.to_v; + let to_axis = draw.to_axis; + let base = draw.base; + let text_col = draw.text_color; let mut err_marks: Vec<(f64, f64, f64, f64, bool)> = vec![]; if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = @@ -748,124 +1053,77 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &t, lr, - base * 0.9, - if horiz { Align::Left } else { Align::Center }, - if horiz { Anchor::Middle } else { Anchor::Bottom }, - text_col, - false, + LabelStyle { + size: base * 0.9, + align: if horiz { Align::Left } else { Align::Center }, + anchor: if horiz { Anchor::Middle } else { Anchor::Bottom }, + col: text_col, + bold: false, + }, ); } } } } - // A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series. - if c.kind == ChartType::Stock { - for i in 0..ncat { - crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v)); - } + err_marks +} + +fn draw_pie(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let Some(s) = c.series.first() else { return }; + let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); + let total: f64 = vals.iter().sum(); + if total <= 0.0 { + return; } - // Lines, areas, scatter: the series that aren't bars. - let mut acc = vec![0.0; ncat]; - for (si, s) in c.series.iter().enumerate() { - let kind = c.series_kind(s); - if kind.is_bar_like() || kind == ChartType::Stock { - continue; - } - let sec = c.on_secondary(s); - let stacked = kind.stacked(); - let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); - let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; - let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values)); - let mut pts = vec![]; - for i in 0..ncat { - let Some(v) = s.values.get(i).copied().flatten() else { continue }; - let v = if stacked { - let base_v = acc.get(i).copied().unwrap_or(0.0); - if let Some(a) = acc.get_mut(i) { - *a += v; - } - base_v + v - } else { - v - }; - let x = match kind { - ChartType::Scatter | ChartType::Bubble => { - match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { - (Some(x), _) => x, - // No x values at all: the points are numbered 1, 2, 3... - (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), - (None, Some(_)) => continue, - } - } - _ => plot.x0 + band * (slot(i) + 0.5), - }; - if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| series_stats.as_ref().and_then(|st| e.range_in(st, i, v))) { - err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); - } - pts.push(Point::new(x, to_axis(sec, v))); - } - if pts.is_empty() { - continue; - } - let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) }; - ctx.set_transform(m); - if area_kind { - let mut a = p.clone(); - if let (Some(l), Some(f)) = (pts.last(), pts.first()) { - a.line_to((l.x, zero)); - a.line_to((f.x, zero)); - a.close_path(); - } - ctx.set_paint(color(col, 0.85)); - ctx.fill_path(&a); - } else if kind != ChartType::Scatter { - let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); - ctx.set_paint(color(col, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round)); - ctx.stroke_path(&p); - } - if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) { - ctx.set_paint(color(col, 1.0)); - for q in &pts { - ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); - } - } - } - // A category position (0-based, fractional) as a slot, the way `slot` places the bars. - let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; - draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis); - if !err_marks.is_empty() { + let r = (area.width().min(area.height()) / 2.0) * 0.95; + let cen = area.center(); + let mut a0 = -std::f64::consts::FRAC_PI_2; + for (i, v) in vals.iter().enumerate() { + let sweep = v / total * std::f64::consts::TAU; let mut p = BezPath::new(); - for &(mid, a, b, cap, end_cap) in &err_marks { - // A horizontal bar chart's error bars run across, its value axis being horizontal. - let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; - p.move_to(pt(a, mid)); - p.line_to(pt(b, mid)); - if end_cap { - for e in [a, b] { - p.move_to(pt(e, mid - cap)); - p.line_to(pt(e, mid + cap)); - } - } - } + p.move_to(cen); + let arc = kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0); + p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); + p.extend(arc.append_iter(0.1)); + p.close_path(); ctx.set_transform(m); - ctx.set_paint(color(text_col, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(0.75)); + let col = s + .point_fills + .iter() + .find(|(k, _)| *k == i) + .and_then(|(_, f)| if let Fill::Solid { color: cc } = f { Some(rctx.color(cc, None)) } else { None }) + .unwrap_or_else(|| series_color(rctx, c, i)); + ctx.set_paint(color(col, 1.0)); + ctx.fill_path(&p); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); ctx.stroke_path(&p); + if c.series_data_labels(s) { + let mid = a0 + sweep / 2.0; + let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin()); + label( + ctx, + rctx, + m, + &c.data_label(s, *v), + Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0), + LabelStyle { + size: base, + align: Align::Center, + anchor: Anchor::Middle, + // On the slices themselves: white, unless the chart's text was given a colour. + col: if c.text_color.is_some() { text_col } else { Rgba::WHITE }, + bold: false, + }, + ); + } + a0 += sweep; } - // Axis line. - let mut ax = BezPath::new(); - if horiz { - ax.move_to((zero, plot.y0)); - ax.line_to((zero, plot.y1)); - } else { - ax.move_to((plot.x0, zero)); - ax.line_to((plot.x1, zero)); + if c.kind == ChartType::Doughnut { + ctx.set_transform(m); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.fill_path(&kurbo::Circle::new(cen, r * c.hole_size.clamp(0.1, 0.9)).to_path(0.1)); } - ctx.set_transform(m); - ctx.set_paint(color(Rgba::rgb(0xBF, 0xBF, 0xBF), 1.0)); - ctx.set_stroke(kurbo::Stroke::new(0.75)); - ctx.stroke_path(&ax); } /// `v` kept within `lo..=hi` without `f64::clamp`'s panic when `lo > hi` (a chart smaller than @@ -943,36 +1201,35 @@ fn forecast_pad(c: &Chart) -> (f64, f64) { const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, 0x30), Rgba::rgb(0x1A, 0x5C, 0x7A), Rgba::rgb(0xA0, 0x68, 0x00)]; -fn map_xy( +struct XYMapper<'a> { plot: Rect, band: f64, - slot: &impl Fn(f64) -> f64, + slot: &'a dyn Fn(f64) -> f64, horiz: bool, scatter: bool, - sx0: f64, - sx1: f64, - sec: bool, - x: f64, - y: f64, - to_axis: &impl Fn(bool, f64) -> f64, -) -> Option { + scatter_x: (f64, f64), + secondary: bool, + to_axis: &'a dyn Fn(bool, f64) -> f64, +} + +fn map_xy(mapper: &XYMapper<'_>, x: f64, y: f64) -> Option { if !x.is_finite() || !y.is_finite() { return None; } - let value = to_axis(sec, y); + let value = (mapper.to_axis)(mapper.secondary, y); if !value.is_finite() { return None; } - let along = if scatter && sx1 > sx0 { - plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width() + let along = if mapper.scatter && mapper.scatter_x.1 > mapper.scatter_x.0 { + mapper.plot.x0 + (x - mapper.scatter_x.0) / (mapper.scatter_x.1 - mapper.scatter_x.0) * mapper.plot.width() } else { - let origin = if horiz { plot.y0 } else { plot.x0 }; - origin + band * (slot(x - 1.0) + 0.5) + let origin = if mapper.horiz { mapper.plot.y0 } else { mapper.plot.x0 }; + origin + mapper.band * ((mapper.slot)(x - 1.0) + 0.5) }; if !along.is_finite() { return None; } - Some(if horiz && !scatter { Point::new(value, along) } else { Point::new(along, value) }) + Some(if mapper.horiz && !mapper.scatter { Point::new(value, along) } else { Point::new(along, value) }) } /// The part of segment `a`โ€“`b` that lies inside `r`. @@ -1006,21 +1263,20 @@ fn clip_seg(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { Some((Point::new(a.x + t0 * dx, a.y + t0 * dy), Point::new(a.x + t1 * dx, a.y + t1 * dy))) } -fn draw_trendlines( - ctx: &mut RenderContext, - rctx: &Ctx, - c: &Chart, - m: Affine, +struct TrendlineLayout<'a> { plot: Rect, band: f64, - slot: &impl Fn(f64) -> f64, + slot: &'a dyn Fn(f64) -> f64, xscale: Option<(f64, f64, f64)>, ncat: usize, horiz: bool, base: f64, text_col: Rgba, - to_axis: &impl Fn(bool, f64) -> f64, -) { + to_axis: &'a dyn Fn(bool, f64) -> f64, +} + +fn draw_trendlines(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, layout: TrendlineLayout<'_>) { + let TrendlineLayout { plot, band, slot, xscale, ncat, horiz, base, text_col, to_axis } = layout; let mut label_n = 0usize; for s in &c.series { if s.trendlines.is_empty() { @@ -1057,6 +1313,7 @@ fn draw_trendlines( } else { (0.0, 0.0) }; + let mapper = XYMapper { plot, band, slot, horiz, scatter, scatter_x: (sx0, sx1), secondary: sec, to_axis }; for (ti, t) in s.trendlines.iter().take(8).enumerate() { let Some(fit) = t.fit(&xs, &ys) else { continue }; let (x0, x1) = fit.span(t.forward, t.backward); @@ -1066,10 +1323,7 @@ fn draw_trendlines( let mut ink_pts = Vec::new(); let mut any = false; for w in samples.windows(2) { - let (Some(a), Some(b)) = ( - map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), - map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), - ) else { + let (Some(a), Some(b)) = (map_xy(&mapper, w[0].0, w[0].1), map_xy(&mapper, w[1].0, w[1].1)) else { prev = None; continue; }; @@ -1117,11 +1371,7 @@ fn draw_trendlines( m, &caption, Rect::new(x, y, x + box_w, y + box_h), - (base * 0.9).clamp(8.0, 13.0), - Align::Left, - Anchor::Top, - text_col, - false, + LabelStyle { size: (base * 0.9).clamp(8.0, 13.0), align: Align::Left, anchor: Anchor::Top, col: text_col, bold: false }, ); label_n += 1; } diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs index 55350d5f..9a9a66b8 100644 --- a/crates/render/src/chartex.rs +++ b/crates/render/src/chartex.rs @@ -62,9 +62,23 @@ fn funnel( ctx.set_paint(color(series_color(rctx, c, 0), 1.0)); ctx.fill_rect(&r); if let Some(name) = c.categories.get(i) { - label(ctx, rctx, m, name, Rect::new(area.x0, y, plot.x0 - base * 0.4, y + band), base, Align::Right, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + name, + Rect::new(area.x0, y, plot.x0 - base * 0.4, y + band), + crate::chart::LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, + ); } - label(ctx, rctx, m, &value_label(*v), Rect::new(cx - 60.0, y, cx + 60.0, y + band), base, Align::Center, Anchor::Middle, Rgba::WHITE, false); + label( + ctx, + rctx, + m, + &value_label(*v), + Rect::new(cx - 60.0, y, cx + 60.0, y + band), + crate::chart::LabelStyle { size: base, align: Align::Center, anchor: Anchor::Middle, col: Rgba::WHITE, bold: false }, + ); } } @@ -118,7 +132,20 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: } let inner = t.inset(-base * 0.25); if inner.width() > base && inner.height() > base * 0.8 { - label(ctx, rctx, m, &text, inner, base * 0.9, Align::Center, Anchor::Middle, if dark { Rgba::WHITE } else { text_col }, false); + label( + ctx, + rctx, + m, + &text, + inner, + crate::chart::LabelStyle { + size: base * 0.9, + align: Align::Center, + anchor: Anchor::Middle, + col: if dark { Rgba::WHITE } else { text_col }, + bold: false, + }, + ); } } } @@ -136,9 +163,23 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: ctx.fill_rect(&Rect::new(x0, bar.y0, x1.min(bar.x1), bar.y1)); } let ly = Rect::new(bar.x0 - base * 8.0, bar.y0 - base * 0.2, bar.x0 - base * 0.4, bar.y1 + base * 0.2); - label(ctx, rctx, m, &c.data_label(s, min), ly, base, Align::Right, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + &c.data_label(s, min), + ly, + crate::chart::LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, + ); let ry = Rect::new(bar.x1 + base * 0.4, bar.y0 - base * 0.2, bar.x1 + base * 8.0, bar.y1 + base * 0.2); - label(ctx, rctx, m, &c.data_label(s, max), ry, base, Align::Left, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + &c.data_label(s, max), + ry, + crate::chart::LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false }, + ); } let note = Rect::new(area.x0, area.y1 - note_h, area.x1, area.y1); label( @@ -147,11 +188,13 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: m, "Map preview unavailable: regions shown as tiles", note, - base * 0.85, - Align::Center, - Anchor::Middle, - mix(text_col, Rgba::WHITE, 0.4), - false, + crate::chart::LabelStyle { + size: base * 0.85, + align: Align::Center, + anchor: Anchor::Middle, + col: mix(text_col, Rgba::WHITE, 0.4), + bold: false, + }, ); } @@ -248,7 +291,14 @@ fn treemap( } let inner = t.inset(-base * 0.3); if inner.width() > base && inner.height() > base { - label(ctx, rctx, m, &text, inner, base, Align::Left, Anchor::Top, Rgba::WHITE, false); + label( + ctx, + rctx, + m, + &text, + inner, + crate::chart::LabelStyle { size: base, align: Align::Left, anchor: Anchor::Top, col: Rgba::WHITE, bold: false }, + ); } } } @@ -289,11 +339,7 @@ fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec m, name, Rect::new(lp.x - 50.0, lp.y - base, lp.x + 50.0, lp.y + base), - base, - Align::Center, - Anchor::Middle, - Rgba::WHITE, - false, + crate::chart::LabelStyle { size: base, align: Align::Center, anchor: Anchor::Middle, col: Rgba::WHITE, bold: false }, ); } a0 = a1; @@ -344,7 +390,14 @@ fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: } let inner = t.inset(-base * 0.3); if inner.width() > base && inner.height() > base { - label(ctx, rctx, m, &text, inner, base * 0.9, Align::Left, Anchor::Top, Rgba::WHITE, false); + label( + ctx, + rctx, + m, + &text, + inner, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Left, anchor: Anchor::Top, col: Rgba::WHITE, bold: false }, + ); } continue; } @@ -355,7 +408,14 @@ fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: ctx.fill_rect(&t); if t.width() > base * 2.0 && banner > base * 0.8 { let head = Rect::new(t.x0 + base * 0.3, t.y0, t.x1 - base * 0.3, t.y0 + banner); - label(ctx, rctx, m, &n.name, head, base, Align::Left, Anchor::Middle, Rgba::WHITE, true); + label( + ctx, + rctx, + m, + &n.name, + head, + crate::chart::LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: Rgba::WHITE, bold: true }, + ); } let inner = Rect::new(t.x0 + 1.0, t.y0 + banner, t.x1 - 1.0, t.y1 - 1.0); let k = lighten(fill, 0.12 * (depth + 1) as f64); @@ -437,11 +497,7 @@ fn tree_sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area m, &n.name, Rect::new(lp.x - half, lp.y - base, lp.x + half, lp.y + base), - base * 0.9, - Align::Center, - Anchor::Middle, - Rgba::WHITE, - false, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Center, anchor: Anchor::Middle, col: Rgba::WHITE, bold: false }, ); } if !n.children.is_empty() { diff --git a/crates/render/src/opts.rs b/crates/render/src/opts.rs index c6579b9b..898d84cc 100644 --- a/crates/render/src/opts.rs +++ b/crates/render/src/opts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DateTime, Fields, Rgba, ShapeId, Vec2, datetime}; /// Animation state of one shape during a slide show or preview. #[derive(Clone, Debug)] diff --git a/crates/render/src/radar.rs b/crates/render/src/radar.rs index e9c9255c..9e8df451 100644 --- a/crates/render/src/radar.rs +++ b/crates/render/src/radar.rs @@ -67,7 +67,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec Align::Right => q.x - w + base * 0.3, _ => q.x - w / 2.0, }; - label(ctx, rctx, m, name, Rect::new(x0, q.y - base * 0.7, x0 + w, q.y + base * 0.7), base, align, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + name, + Rect::new(x0, q.y - base * 0.7, x0 + w, q.y + base * 0.7), + crate::chart::LabelStyle { size: base, align, anchor: Anchor::Middle, col: text_col, bold: false }, + ); } } // Value labels up the first spoke. @@ -83,11 +90,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec m, &t, Rect::new(q.x - w - base * 0.3, q.y - base * 0.6, q.x - base * 0.3, q.y + base * 0.6), - base * 0.9, - Align::Right, - Anchor::Middle, - text_col, - false, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, ); } } @@ -130,11 +133,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec m, &t, Rect::new(q.x - 40.0, q.y - base * 1.4, q.x + 40.0, q.y - base * 0.2), - base * 0.9, - Align::Center, - Anchor::Bottom, - text_col, - false, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Center, anchor: Anchor::Bottom, col: text_col, bold: false }, ); } } diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 510cb6c5..fdabb213 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -60,6 +60,50 @@ pub(crate) struct Frame<'a> { pub(crate) fields: SlideFields, } +/// A shape being drawn: its resolved geometry, fill, line and effects, in shape space `m` at size +/// `w` x `h`, shared by the stages of `Renderer::geometry_shape`. +struct ShapeDraw<'a, 'ctx> { + f: &'a Frame<'a>, + rctx: &'a Ctx<'ctx>, + s: &'a Shape, + st: &'a ShapeState, + m: Affine, + w: f64, + h: f64, + depth: usize, + geo: Geometry, + fades: Vec, + fill: Option, + fill_ph: Option, + line: Line, + line_ph: Option, + effects: Option, + fx_ph: Option, + outline: BezPath, + is_pic: bool, + empty_ph: bool, +} + +impl ShapeDraw<'_, '_> { + /// How much of the fill and stroke of path `i` shows (morphing outlines fade). + fn fade(&self, i: usize) -> deckcraft_geom::morph::Fade { + self.fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }) + } +} + +/// What casts a shape's shadow, glow and reflection: its geometry, fill and outline, in shape +/// space `m` at size `w` x `h`. +struct FxSource<'a> { + geo: &'a Geometry, + fill: &'a Option, + fill_alpha: Option, + outline: Option>, + content: Option<&'a deckcraft_model::style::PictureFill>, + m: Affine, + w: f64, + h: f64, +} + impl Renderer { fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) { let (w, h) = output_size(pres, opts); @@ -541,7 +585,6 @@ impl Renderer { Some(pm) => self.morphs.geometry(pm, s, w, h), None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()), }; - let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }); let (fill, fill_ph) = resolve::fill(rctx, s); let (line, line_ph) = resolve::line(rctx, s); let (effects, fx_ph) = resolve::effects(rctx, s); @@ -552,6 +595,56 @@ impl Renderer { // Placeholders in the editor: dashed outline and prompt, nothing else when empty. let empty_ph = s.ph.is_some() && s.text.as_ref().is_none_or(TextBody::is_empty) && !is_pic && !matches!(s.kind, ShapeKind::Media(_)); let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill }; + let d = ShapeDraw { f, rctx, s, st, m, w, h, depth, geo, fades, fill, fill_ph, line, line_ph, effects, fx_ph, outline, is_pic, empty_ph }; + self.cast_effects(ctx, &d); + let soft = d.effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); + if let Some(r) = soft { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, None, None, None); + let _ = r; + } + let pt = self.point_scale(&d); + self.draw_extrusion(ctx, &d, pt); + self.draw_contour(ctx, &d, pt); + self.draw_fill(ctx, &d); + self.draw_fill_overlay(ctx, &d); + self.draw_bevel(ctx, &d, pt); + self.draw_outline(ctx, &d); + if let Some(r) = soft { + self.draw_feather(ctx, &d, r); + } + self.draw_inner_shadow(ctx, &d); + self.draw_text_body(ctx, &d); + } + + /// Shape-space units per point for the 3-D sizes, measured without the camera's turn (which + /// `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale. + fn point_scale(&self, d: &ShapeDraw) -> f64 { + let turn = d + .effects + .as_ref() + .and_then(|e| e.raw3d.as_deref()) + .and_then(camera::from_raw) + .and_then(|c| camera::affine(&c, d.w, d.h)) + .filter(|t| t.determinant().abs() > 1e-9); + self.per_point(turn.map_or(d.m, |t| d.m * t.inverse()), d.depth) + } + + fn cast_effects(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let depth = d.depth; + let fill_ph = d.fill_ph; + let line_ph = d.line_ph; + let fx_ph = d.fx_ph; + let is_pic = d.is_pic; + let s = d.s; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let effects = &d.effects; + let line = &d.line; // Effects below the shape. if let Some(e) = &effects { // A picture (or picture fill) casts its shadow from its pixels, not its frame. @@ -560,7 +653,7 @@ impl Renderer { _ => None, }; // What casts the shadow: the fill as see-through as it is, and the outline. - let fill_alpha = fill_opacity(rctx, &fill, fill_ph); + let fill_alpha = fill_opacity(rctx, fill, fill_ph); let outline = line.fill.as_ref().filter(|f| !f.is_none()).map(|lf| { let a = match lf { Fill::Solid { color: c } => rctx.color(c, line_ph).a as f64 / 255.0, @@ -568,28 +661,22 @@ impl Renderer { }; let (base, rel) = self.line_space(m, depth); let lw = line.width.unwrap_or(0.75).max(0.0); - let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(&line, lw) } else { None }; - Caster { line: outside.map_or(std::borrow::Cow::Borrowed(&line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } + let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(line, lw) } else { None }; + Caster { line: outside.map_or(std::borrow::Cow::Borrowed(line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } }); - self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, fill_alpha, outline, content.as_ref(), m, w, h); + self.effects_below(ctx, rctx, e, fx_ph, FxSource { geo, fill, fill_alpha, outline, content: content.as_ref(), m, w, h }); } - let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); - if let Some(r) = soft { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, None, None, None); - let _ = r; - } - // Shape-space units per point for the 3-D sizes, measured without the camera's turn (which - // `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale. - let pt = { - let turn = effects - .as_ref() - .and_then(|e| e.raw3d.as_deref()) - .and_then(camera::from_raw) - .and_then(|c| camera::affine(&c, w, h)) - .filter(|t| t.determinant().abs() > 1e-9); - self.per_point(turn.map_or(m, |t| m * t.inverse()), depth) - }; + } + + fn draw_extrusion(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { + let m = d.m; + let w = d.w; + let h = d.h; + let fill_ph = d.fill_ph; + let rctx = d.rctx; + let fill = &d.fill; + let effects = &d.effects; + let outline = &d.outline; // A 3-D extrusion under a turned camera: the sides, swept behind the face. if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) && let Some(ex) = extrude::from_raw(raw) @@ -604,8 +691,17 @@ impl Renderer { let pre = m * turn.inverse(); let lit = light::Lighting::from_raw(raw); let d = ex.depth * pt; - extrude::draw(ctx, &outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit); + extrude::draw(ctx, outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit); } + } + + fn draw_contour(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { + let m = d.m; + let fill_ph = d.fill_ph; + let rctx = d.rctx; + let fill = &d.fill; + let effects = &d.effects; + let outline = &d.outline; // A 3-D contour: a band around the outline, behind the face (and in front of any sides). if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) && let Some(c) = extrude::contour_from_raw(raw) @@ -615,8 +711,23 @@ impl Renderer { _ => None, }; let col = c.color.as_ref().map(|k| rctx.color(k, face)).or(face).unwrap_or(Rgba::rgb(0xff, 0xff, 0xff)); - extrude::draw_contour(ctx, &outline, m, c.width * pt, col, &light::Lighting::from_raw(raw)); + extrude::draw_contour(ctx, outline, m, c.width * pt, col, &light::Lighting::from_raw(raw)); } + } + + fn draw_fill(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let fill_ph = d.fill_ph; + let empty_ph = d.empty_ph; + let s = d.s; + let f = d.f; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let outline = &d.outline; + let fade = |i: usize| d.fade(i); // Fill. match &s.kind { ShapeKind::Picture { fill: pf } => { @@ -625,11 +736,11 @@ impl Renderer { if s.fill.as_ref().is_some_and(|f| !f.is_none()) && let Some(fl) = &fill { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); } ctx.set_transform(m); let bounds = Rect::new(0.0, 0.0, w, h); - ctx.push_clip_layer(&outline); + ctx.push_clip_layer(outline); let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); ctx.pop_layer(); @@ -642,7 +753,7 @@ impl Renderer { if s.fill.as_ref().is_some_and(|f| !f.is_none()) && let Some(fl) = &fill { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); } ctx.set_transform(m); let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() }; @@ -653,7 +764,7 @@ impl Renderer { let bounds = Rect::new(0.0, 0.0, w, h); if let Some(p) = mc.poster { let pf = deckcraft_model::style::PictureFill { media: p, crop: mc.crop, ..Default::default() }; - ctx.push_clip_layer(&outline); + ctx.push_clip_layer(outline); paint::picture(ctx, f.pres, &pf, bounds, m, None); ctx.pop_layer(); } else if mc.video { @@ -668,10 +779,23 @@ impl Renderer { if let Some(fl) = &fill && !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none()) { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); } } } + } + + fn draw_fill_overlay(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let fx_ph = d.fx_ph; + let f = d.f; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let effects = &d.effects; + let fade = |i: usize| d.fade(i); // `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply // a gradient over a tiled picture), inside the filled outline, under the line. if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref()) @@ -691,13 +815,19 @@ impl Renderer { ctx.pop_layer(); } } - // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). - let line = match (&s.geom, line.join) { - (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { - Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } - } - _ => line, - }; + } + + fn draw_bevel(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { + let m = d.m; + let w = d.w; + let h = d.h; + let fill_ph = d.fill_ph; + let is_pic = d.is_pic; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let effects = &d.effects; + let outline = &d.outline; // A 3-D bevel: the face and a band inside the outline lit as surfaces. if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) { let bv = bevel::Bevel { w: bv.w * pt, h: bv.h * pt, ..bv }; @@ -717,6 +847,29 @@ impl Renderer { bevel::draw(ctx, &bv, if is_pic { None } else { solid }, &area, Rect::new(0.0, 0.0, w, h), m, 1.0); } } + } + + fn draw_outline(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let depth = d.depth; + let line_ph = d.line_ph; + let is_pic = d.is_pic; + let s = d.s; + let rctx = d.rctx; + let geo = &d.geo; + let effects = &d.effects; + let outline = &d.outline; + let line = d.line.clone(); + let fade = |i: usize| d.fade(i); + // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). + let line = match (&s.geom, line.join) { + (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { + Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } + } + _ => line, + }; // Outline. if line.fill.as_ref().is_some_and(|f| !f.is_none()) { let lw = line.width.unwrap_or(0.75).max(0.0); @@ -779,7 +932,7 @@ impl Renderer { stroke(ctx, rctx, &line, line_ph, &path, Some(&whole), base, lw, fade(i).stroke); } if ends { - arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw); + arrowheads(ctx, rctx, &line, line_ph, geo, rel, base, lw); } if clipped { ctx.pop_layer(); @@ -788,33 +941,46 @@ impl Renderer { ctx.pop_layer(); } } - if let Some(r) = soft { - // Feather: the shape (fill and line) fades to nothing at its outline. Measured on - // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of - // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. - ctx.set_transform(m); - ctx.push_layer( - None, - Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), - None, - None, - Some(blur(r / 3.0 * scale_of(m))), - ); - ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); - ctx.fill_path(&outline); - ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); - ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); - ctx.stroke_path(&outline); - ctx.pop_layer(); - ctx.pop_layer(); - ctx.pop_layer(); - } + } + + fn draw_feather(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, r: f64) { + let m = d.m; + let outline = &d.outline; + // Feather: the shape (fill and line) fades to nothing at its outline. Measured on + // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of + // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. + ctx.set_transform(m); + ctx.push_layer( + None, + Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), + None, + None, + Some(blur(r / 3.0 * scale_of(m))), + ); + ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); + ctx.fill_path(outline); + ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); + ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); + ctx.stroke_path(outline); + ctx.pop_layer(); + ctx.pop_layer(); + ctx.pop_layer(); + } + + fn draw_inner_shadow(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let fx_ph = d.fx_ph; + let rctx = d.rctx; + let effects = &d.effects; + let outline = &d.outline; // Inner shadow. if let Some(e) = &effects && let Some(sh) = &e.inner_shadow { ctx.set_transform(m); - ctx.push_clip_layer(&outline); + ctx.push_clip_layer(outline); let c = rctx.color(&sh.color, fx_ph); let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; // As PowerPoint draws it (measured on its export): the shadow falls inside along the @@ -832,6 +998,19 @@ impl Renderer { ctx.pop_layer(); ctx.pop_layer(); } + } + + fn draw_text_body(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let depth = d.depth; + let empty_ph = d.empty_ph; + let s = d.s; + let f = d.f; + let rctx = d.rctx; + let st = d.st; + let geo = &d.geo; // Text (or the placeholder prompt in the editor). let skip_text = f.opts.skip_text == Some(s.id); if let Some(body) = &s.text @@ -842,7 +1021,7 @@ impl Renderer { let (tw, th, m) = self.text_space(m, depth, w, h); let text_geo; let geo = if (tw, th) == (w, h) { - &geo + geo } else { text_geo = shape_geometry(s, tw, th); &text_geo @@ -878,7 +1057,7 @@ impl Renderer { let prompt_body = prompt_body(rctx, s, kind); if !prompt_body.is_empty() { let grey = Rgba::rgb(0x59, 0x59, 0x59); - draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey)); + draw_text(ctx, rctx, s, &prompt_body, m, &Opts { rect: tr, fields: &f.fields, prompt_color: Some(grey), no_shrink: false }); } } } @@ -895,21 +1074,8 @@ impl Renderer { } } - fn effects_below( - &mut self, - ctx: &mut RenderContext, - rctx: &Ctx, - e: &Effects, - ph: Option, - geo: &Geometry, - fill: &Option, - fill_alpha: Option, - outline: Option, - content: Option<&deckcraft_model::style::PictureFill>, - m: Affine, - w: f64, - h: f64, - ) { + fn effects_below(&mut self, ctx: &mut RenderContext, rctx: &Ctx, e: &Effects, ph: Option, src: FxSource) { + let FxSource { geo, fill, fill_alpha, outline, content, m, w, h } = src; let silhouette = { let mut p = BezPath::new(); for sp in &geo.paths { diff --git a/crates/render/src/slide.rs b/crates/render/src/slide.rs index 77040d97..b3e6e5de 100644 --- a/crates/render/src/slide.rs +++ b/crates/render/src/slide.rs @@ -1,7 +1,7 @@ use super::opts::{RenderOpts, SlideFields}; use super::raster::{Image, MAX_SIDE}; use super::renderer::{Frame, RENDERER, Renderer}; -use super::*; +use super::{Affine, Ctx, PathMorph, Presentation, Rect, RenderContext, Shape, ShapeId, Slide, TextMorph, resolve}; static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -125,8 +125,7 @@ pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opt /// to `shapes` only. pub fn render_blend( pres: &Presentation, - old: usize, - new: usize, + (old, new): (usize, usize), mix: f64, shapes: &[(Shape, bool)], text: &[TextMorph], diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index bcddf4c9..cddf1e37 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -616,14 +616,14 @@ fn blend_ends_match_the_slides_and_mixes_backdrops() { let o = RenderOpts { scale: 0.5, ..Default::default() }; let shapes = vec![(sh, false)]; // At the ends the frame is the slide itself; the shape stays opaque throughout. - assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &[], &o).pixels, render_slide(&p, 1, &o).pixels); - let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &[], &o); + assert_eq!(render_blend(&p, (0, 1), 1.0, &shapes, &[], &[], &o).pixels, render_slide(&p, 1, &o).pixels); + let mid = render_blend(&p, (0, 1), 0.5, &shapes, &[], &[], &o); let px = mid.pixel(5, 5); assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}"); assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75)); // Hostile input: bad indices and NaN never panic. - assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &[], &o).width, 0); - let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &[], &o); + assert_eq!(render_blend(&p, (0, 9), 0.5, &shapes, &[], &[], &o).width, 0); + let _ = render_blend(&p, (0, 1), f64::NAN, &shapes, &[], &[], &o); } fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape { @@ -668,7 +668,7 @@ fn morph_text_ends_match_the_slides() { sb.xfrm = Some(x); sa.xfrm = Some(x); let tm = TextMorph { old: ShapeId(11), new: ShapeId(10), from, to, chars: false, t }; - render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &[], &o) + render_blend(&p, (0, 1), t, &[(sb, false), (sa, true)], &[tm], &[], &o) }; // Warm up: faces load lazily (a background scan runs), so the first render may still fall back. let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5)); @@ -781,7 +781,7 @@ fn morph_path_ends_match_the_slides() { let lerp = |a: f64, b: f64| a + (b - a) * t; let x = Xfrm::new(lerp(from.x, to.x), lerp(from.y, to.y), lerp(from.w, to.w), lerp(from.h, to.h)); let pm = PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t }; - render_blend(&p, 0, 1, t, &[(form(10, "star5", x), false)], &[], &[pm], &o) + render_blend(&p, (0, 1), t, &[(form(10, "star5", x), false)], &[], &[pm], &o) }; // The morph splits the outlines into more curves, which flatten slightly differently: edge pixels // may differ a little, a different shape differs a lot. @@ -792,7 +792,7 @@ fn morph_path_ends_match_the_slides() { let d1 = differ(&frame(1.0), &render_slide(&p, 1, &o)); assert!(d1 < 40, "end differs {d1}"); // Without the morph the start would show the star. - let star = render_blend(&p, 0, 1, 0.0, &[(form(10, "star5", from), false)], &[], &[], &o); + let star = render_blend(&p, (0, 1), 0.0, &[(form(10, "star5", from), false)], &[], &[], &o); assert!(differ(&star, &render_slide(&p, 0, &o)) > 400); // Halfway the shape is filled around the middle of its box. let mid = frame(0.5); @@ -818,7 +818,7 @@ fn morph_path_fades_a_fill_only_one_side_has() { let at = |t: f64| { let pm = PathMorph { id: ShapeId(10), from: Geom::preset("rect"), from_box: bx, to_box: bx, t }; // A point near the line but not on it: inside the shape all the way. - render_blend(&p, 0, 1, t, &[(form(10, "line"), false)], &[], &[pm], &o).pixel(110, 90) + render_blend(&p, (0, 1), t, &[(form(10, "line"), false)], &[], &[pm], &o).pixel(110, 90) }; let (full, half, none) = (at(0.0), at(0.5), at(1.0)); assert_eq!(none, [255, 255, 255, 255]); diff --git a/crates/render/src/text_draw.rs b/crates/render/src/text_draw.rs index 095eedab..3fe96d69 100644 --- a/crates/render/src/text_draw.rs +++ b/crates/render/src/text_draw.rs @@ -1,6 +1,8 @@ use super::geometry::{blur, color, pre_translate, scale_of}; use super::opts::ParaState; -use super::*; +use super::{ + Affine, Arc, BezPath, Ctx, FontDb, HashMap, Opts, PhType, Rect, RenderContext, Rgba, Shape, TextBody, Vec2, colr, paint, peniko, resolve, +}; /// The prompt shown in an empty placeholder, formatted like its content would be. pub(crate) fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody { @@ -53,10 +55,10 @@ pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc p } -/// Draw a text body into shape space `m` within the text rect. -pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody, tr: Rect, m: Affine, fields: &dyn Fields, prompt: Option) { - let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: prompt, no_shrink: false }); - draw_layout(ctx, &l, tr, m); +/// Draw a text body into shape space `m` within the text rect `opts.rect`. +pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody, m: Affine, opts: &Opts) { + let l = deckcraft_text::layout(rctx, s, body, opts); + draw_layout(ctx, &l, opts.rect, m); } /// Add transient list markers to an editor layout, using the document's own bullet glyphs. diff --git a/crates/render/src/transition.rs b/crates/render/src/transition.rs index ee760767..ca2a37e1 100644 --- a/crates/render/src/transition.rs +++ b/crates/render/src/transition.rs @@ -87,7 +87,7 @@ pub fn render_transition(pres: &Presentation, index: usize, t: f64, opts: &Rende .iter() .map(|x| crate::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) .collect(); - return render_blend(pres, from, index, m.mix, &m.shapes, &text, &paths, &o); + return render_blend(pres, (from, index), m.mix, &m.shapes, &text, &paths, &o); } } let new = render_slide(pres, index, opts); diff --git a/crates/text/src/bidi.rs b/crates/text/src/bidi.rs index ac9b3afd..cc8d0aac 100644 --- a/crates/text/src/bidi.rs +++ b/crates/text/src/bidi.rs @@ -66,109 +66,16 @@ pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[boo if classes.iter().any(|c| matches!(c, LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI)) { return levels(text, chars, rtl); } - let n = chars.len(); - let rl = |i: usize| rtl_lang.get(i).copied().unwrap_or(false); - let sos = if rtl { Dir::R } else { Dir::L }; - // W1: combining marks take the class of the character they sit on. - let mut cls = classes; - for i in 0..n { - if cls.get(i) == Some(&NSM) { - let prev = if i == 0 { if rtl { R } else { L } } else { cls.get(i - 1).copied().unwrap_or(L) }; - if let Some(c) = cls.get_mut(i) { - *c = prev; - } - } - } - let mut dir = vec![Dir::Open; n]; - // Strong letters and digits. - let mut last_strong: Option = None; - for i in 0..n { - let c = cls.get(i).copied().unwrap_or(ON); - let d = match c { - L => Dir::L, - R | AL => Dir::R, - AN => Dir::Num, - EN if !rl(i) => Dir::L, - // W7: European digits after left-to-right text are left to right. - EN if last_strong == Some(L) => Dir::L, - EN => Dir::Num, - _ => Dir::Open, - }; - if matches!(c, L | R | AL) { - last_strong = Some(c); - } - if let Some(x) = dir.get_mut(i) { - *x = d; - } - } - // W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next - // to them, belong to the number. `after_et[i]` is the first index after `i` that is not ET, - // computed once so a long run of currency signs costs linear work, not a scan per sign. - let after_et = next_non_et(&cls); - for i in 0..n { - if !rl(i) || dir.get(i) != Some(&Dir::Open) { - continue; - } - let c = cls.get(i).copied().unwrap_or(ON); - let num = |j: Option| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num); - let joined = match c { - ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)), - ET => num(i.checked_sub(1)) || num(after_et.get(i).copied().flatten()), - _ => false, - }; - if joined && let Some(x) = dir.get_mut(i) { - *x = Dir::Num; - } - } + let cls = resolve_nsm(classes, rtl); + let mut dir = initial_dirs(&cls, rtl_lang); + join_numbers_in_rtl_runs(&mut dir, &cls, rtl_lang); // Punctuation in left-to-right runs is left to right. - for i in 0..n { - if !rl(i) - && dir.get(i) == Some(&Dir::Open) - && !matches!(cls.get(i), Some(WS | S)) - && let Some(x) = dir.get_mut(i) - { - *x = Dir::L; - } - } - // Neutrals in right-to-left runs: the direction around them when both sides agree, else - // right to left. Unresolved spaces of left-to-right runs are looked through. - let strong_at = |dir: &[Dir], j: usize| match dir.get(j) { - Some(Dir::L) => Some(Dir::L), - Some(Dir::R | Dir::Num) => Some(Dir::R), - _ => None, - }; - // Resolving a span only changes characters before the scan position, so the nearest strong - // direction after a span can be computed once up front, and the one before it is carried along: - // linear work however many neutral spans the paragraph has. - let mut next_strong: Vec> = vec![None; n + 1]; - for j in (0..n).rev() { - let here = strong_at(&dir, j).or(next_strong.get(j + 1).copied().flatten()); - if let Some(x) = next_strong.get_mut(j) { - *x = here; - } - } - let mut last_strong: Option = None; - let mut i = 0; - while i < n { - if !(rl(i) && dir.get(i) == Some(&Dir::Open)) { - last_strong = strong_at(&dir, i).or(last_strong); - i += 1; - continue; - } - let start = i; - while i < n && rl(i) && dir.get(i) == Some(&Dir::Open) { - i += 1; - } - let before = last_strong.unwrap_or(sos); - let after = next_strong.get(i).copied().flatten().unwrap_or(sos); - let d = if before == after { before } else { Dir::R }; - for x in dir.get_mut(start..i).into_iter().flatten() { - *x = d; - } - if start < i { - last_strong = Some(if d == Dir::Num { Dir::R } else { d }); + for (i, d) in dir.iter_mut().enumerate() { + if !in_rtl_lang(rtl_lang, i) && *d == Dir::Open && !matches!(cls.get(i), Some(WS | S)) { + *d = Dir::L; } } + resolve_rtl_neutrals(&mut dir, rtl_lang, if rtl { Dir::R } else { Dir::L }); // Spaces in left-to-right runs follow the character before them. let mut prev = Dir::L; for d in dir.iter_mut() { @@ -187,6 +94,115 @@ pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[boo .collect() } +fn in_rtl_lang(rtl_lang: &[bool], i: usize) -> bool { + rtl_lang.get(i).copied().unwrap_or(false) +} + +/// W1: combining marks take the class of the character they sit on. +fn resolve_nsm(mut cls: Vec, rtl: bool) -> Vec { + for i in 0..cls.len() { + if cls.get(i) == Some(&NSM) { + let prev = if i == 0 { if rtl { R } else { L } } else { cls.get(i - 1).copied().unwrap_or(L) }; + if let Some(c) = cls.get_mut(i) { + *c = prev; + } + } + } + cls +} + +/// Strong letters and digits get a direction; everything else stays open. +fn initial_dirs(cls: &[BidiClass], rtl_lang: &[bool]) -> Vec { + let mut last_strong: Option = None; + cls.iter() + .enumerate() + .map(|(i, &c)| { + let d = match c { + L => Dir::L, + R | AL => Dir::R, + AN => Dir::Num, + EN if !in_rtl_lang(rtl_lang, i) => Dir::L, + // W7: European digits after left-to-right text are left to right. + EN if last_strong == Some(L) => Dir::L, + EN => Dir::Num, + _ => Dir::Open, + }; + if matches!(c, L | R | AL) { + last_strong = Some(c); + } + d + }) + .collect() +} + +/// W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next +/// to them, belong to the number. `after_et[i]` is the first index after `i` that is not ET, +/// computed once so a long run of currency signs costs linear work, not a scan per sign. +fn join_numbers_in_rtl_runs(dir: &mut [Dir], cls: &[BidiClass], rtl_lang: &[bool]) { + let after_et = next_non_et(cls); + for i in 0..dir.len() { + if !in_rtl_lang(rtl_lang, i) || dir.get(i) != Some(&Dir::Open) { + continue; + } + let c = cls.get(i).copied().unwrap_or(ON); + let num = |j: Option| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num); + let joined = match c { + ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)), + ET => num(i.checked_sub(1)) || num(after_et.get(i).copied().flatten()), + _ => false, + }; + if joined && let Some(x) = dir.get_mut(i) { + *x = Dir::Num; + } + } +} + +fn strong_at(dir: &[Dir], j: usize) -> Option { + match dir.get(j) { + Some(Dir::L) => Some(Dir::L), + Some(Dir::R | Dir::Num) => Some(Dir::R), + _ => None, + } +} + +/// Neutrals in right-to-left runs: the direction around them when both sides agree, else right to +/// left. Unresolved spaces of left-to-right runs are looked through. +/// +/// Resolving a span only changes characters before the scan position, so the nearest strong +/// direction after a span can be computed once up front, and the one before it is carried along: +/// linear work however many neutral spans the paragraph has. +fn resolve_rtl_neutrals(dir: &mut [Dir], rtl_lang: &[bool], sos: Dir) { + let n = dir.len(); + let rl = |dir: &[Dir], i: usize| in_rtl_lang(rtl_lang, i) && dir.get(i) == Some(&Dir::Open); + let mut next_strong: Vec> = vec![None; n + 1]; + for j in (0..n).rev() { + let here = strong_at(dir, j).or(next_strong.get(j + 1).copied().flatten()); + if let Some(x) = next_strong.get_mut(j) { + *x = here; + } + } + let mut last_strong: Option = None; + let mut i = 0; + while i < n { + if !rl(dir, i) { + last_strong = strong_at(dir, i).or(last_strong); + i += 1; + continue; + } + let start = i; + while i < n && rl(dir, i) { + i += 1; + } + let before = last_strong.unwrap_or(sos); + let after = next_strong.get(i).copied().flatten().unwrap_or(sos); + let d = if before == after { before } else { Dir::R }; + for x in dir.get_mut(start..i).into_iter().flatten() { + *x = d; + } + last_strong = Some(if d == Dir::Num { Dir::R } else { d }); + } +} + /// For each index, the first later index whose class is not ET (`None` when only ET follows). fn next_non_et(cls: &[BidiClass]) -> Vec> { let mut out = vec![None; cls.len()]; diff --git a/crates/text/src/breaking.rs b/crates/text/src/breaking.rs index 133eabf5..36b9e592 100644 --- a/crates/text/src/breaking.rs +++ b/crates/text/src/breaking.rs @@ -1,5 +1,5 @@ use super::style::{Cell, ParaShaped}; -use super::*; +use super::{FontFace, ParaProps}; pub(crate) fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) { let g = deckcraft_fonts::shape(face, s, &[], |c| c); @@ -132,16 +132,19 @@ pub(crate) struct RawLine { next: usize, } -pub(crate) fn break_lines( - p: &ParaShaped, - first_w: f64, - rest_w: f64, - wrap: bool, - tab_stops: &[Tab], - default_tab: f64, - x0_first: f64, - x0_rest: f64, -) -> Vec { +/// Wrapping geometry of one paragraph: line widths, start offsets, wrap flag and tab settings. +pub(crate) struct BreakParams<'a> { + pub(crate) first_w: f64, + pub(crate) rest_w: f64, + pub(crate) wrap: bool, + pub(crate) tab_stops: &'a [Tab], + pub(crate) default_tab: f64, + pub(crate) x0_first: f64, + pub(crate) x0_rest: f64, +} + +pub(crate) fn break_lines(p: &ParaShaped, params: &BreakParams) -> Vec { + let BreakParams { first_w, rest_w, wrap, tab_stops, default_tab, x0_first, x0_rest } = *params; let text: String = p.cells.iter().map(|c| c.ch).collect(); let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false)); let hang = p.props.hanging_punct.unwrap_or(false); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c6b5b1a0..f764cf52 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1,8 +1,11 @@ -use super::breaking::{break_lines, line_metrics, measure_str, tab_advance, tab_segment, tabs_of}; +use super::breaking::{BreakParams, RawLine, Tab, break_lines, line_metrics, measure_str, tab_advance, tab_segment, tabs_of}; use super::run::{Deco, DecoLine, GlyphRun}; use super::shape::{SUB_SUPER, Upright, shape_para}; -use super::style::{Opts, ParaShaped, spacing_pts}; -use super::*; +use super::style::{Cell, Opts, ParaShaped, Style, spacing_pts}; +use super::{ + Align, Anchor, Arc, AutoFit, BodyProps, Bullet, Caps, Ctx, FontFace, LineInfo, Rect, Shape, Spacing, Strike, TextBody, TextDir, TextLayout, bidi, + joins_following, joins_preceding, resolve, +}; #[cfg(test)] thread_local! { @@ -63,520 +66,176 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay l } -#[allow(clippy::too_many_arguments)] -pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout { - #[cfg(test)] - LAYOUT_PASSES.with(|n| n.set(n.get() + 1)); - let r = opts.rect; - let vert = bp.vert.unwrap_or(TextDir::Horizontal); - // East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text - // stands every character upright, one under the other. - let upright_text = match vert { - TextDir::EaVertical => Upright::Cjk, - TextDir::Stacked | TextDir::StackedRtl => Upright::All, - _ => Upright::None, - }; - // Mongolian vertical and stacked text: turned like vert, their lines run from left to right - // (as PowerPoint draws both). - let lines_ltr = matches!(vert, TextDir::MongolianVertical | TextDir::Stacked); - let rotation = match vert { - TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::StackedRtl | TextDir::MongolianVertical => 90.0, - TextDir::Vertical270 => 270.0, - TextDir::Horizontal => 0.0, - } + bp.rot.unwrap_or(0.0); - // For vertical text, lay out in a box with swapped dimensions centred on the text rect. - let r = if (rotation - 90.0).abs() < 1e-6 || (rotation - 270.0).abs() < 1e-6 { - let c = r.center(); - Rect::new(c.x - r.height() / 2.0, c.y - r.width() / 2.0, c.x + r.height() / 2.0, c.y + r.width() / 2.0) - } else { - r - }; - let inner = Rect::new( - r.x0 + bp.inset_l.unwrap_or(7.2), - r.y0 + bp.inset_t.unwrap_or(3.6), - (r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)), - (r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)), - ); - // Text that follows a WordArt arch or circle is set on one line per paragraph. - let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow); - let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); - let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize; - let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0); - let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0); +/// Geometry of the text box shared by every line: rotation, inner rectangle, wrapping and columns. +struct BoxGeom { + vert: TextDir, + rotation: f64, + inner: Rect, + wrap: bool, + ncols: usize, + col_gap: f64, + col_w: f64, + rtl_cols: bool, +} - let mut out = TextLayout { inner, font_scale: scale, line_reduction: reduce, rotation, ..Default::default() }; - let mut y = 0.0; +impl BoxGeom { + fn new(shape: &Shape, bp: &BodyProps, text_rect: Rect) -> Self { + let vert = bp.vert.unwrap_or(TextDir::Horizontal); + let rotation = match vert { + TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::StackedRtl | TextDir::MongolianVertical => 90.0, + TextDir::Vertical270 => 270.0, + TextDir::Horizontal => 0.0, + } + bp.rot.unwrap_or(0.0); + // For vertical text, lay out in a box with swapped dimensions centred on the text rect. + let r = if (rotation - 90.0).abs() < 1e-6 || (rotation - 270.0).abs() < 1e-6 { + let c = text_rect.center(); + Rect::new(c.x - text_rect.height() / 2.0, c.y - text_rect.width() / 2.0, c.x + text_rect.height() / 2.0, c.y + text_rect.width() / 2.0) + } else { + text_rect + }; + let inner = Rect::new( + r.x0 + bp.inset_l.unwrap_or(7.2), + r.y0 + bp.inset_t.unwrap_or(3.6), + (r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)), + (r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)), + ); + // Text that follows a WordArt arch or circle is set on one line per paragraph. + let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow); + let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); + let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize; + let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0); + let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0); + let rtl_cols = bp.rtl_col.unwrap_or(false) && ncols > 1; + BoxGeom { vert, rotation, inner, wrap, ncols, col_gap, col_w, rtl_cols } + } + + /// East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text + /// stands every character upright, one under the other. + fn upright(&self) -> Upright { + match self.vert { + TextDir::EaVertical => Upright::Cjk, + TextDir::Stacked | TextDir::StackedRtl => Upright::All, + _ => Upright::None, + } + } + + /// Mongolian vertical and stacked text: turned like vert, their lines run from left to right + /// (as PowerPoint draws both). + fn lines_ltr(&self) -> bool { + matches!(self.vert, TextDir::MongolianVertical | TextDir::Stacked) + } + + /// Where column `c` starts: from the left, or from the right with `rtlCol` (PowerPoint puts the + /// first column on the right). + fn col_x(&self, c: usize) -> f64 { + (if self.rtl_cols { self.ncols.saturating_sub(1 + c) } else { c }) as f64 * (self.col_w + self.col_gap) + } +} + +/// Number shown for a paragraph at list level `lvl`, advancing the per-level counters. +fn next_number(numbering: &mut [u32], lvl: usize, auto_num: bool, empty: bool) -> Option { + if auto_num && !empty { + if let Some(n) = numbering.get_mut(lvl) { + *n += 1; + } + for deeper in numbering.iter_mut().skip(lvl + 1) { + *deeper = 0; + } + numbering.get(lvl).copied() + } else if auto_num { + // Preview the next number without consuming it: empty paragraphs do not advance + // numbering in the saved document or affect later populated paragraphs. + numbering.get(lvl).map(|n| n.saturating_add(1)) + } else { + if !empty { + for n in numbering.iter_mut().skip(lvl) { + *n = 0; + } + } + None + } +} + +fn shape_paragraphs(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts, scale: f64, upright: Upright) -> Vec { let mut numbering: Vec = vec![0; 9]; - let mut all_lines: Vec<(LineInfo, LinePlace, f64, Option<(String, f64, f64)>)> = vec![]; - let mut prev_after = 0.0; - // How far the last line's full pitch reaches past the text (exact spacing; see below). - let mut tail = 0.0; - let first_last = bp.space_first_last.unwrap_or(false); let mut shaped_paras: Vec = Vec::with_capacity(body.paragraphs.len()); for (pi, para) in body.paragraphs.iter().enumerate() { let lvl = para.level.min(8) as usize; let pp = resolve::para(ctx, shape, para); - let number = if matches!(pp.bullet, Some(Bullet::AutoNum { .. })) && !para.is_empty() { - if let Some(n) = numbering.get_mut(lvl) { - *n += 1; - } - for deeper in numbering.iter_mut().skip(lvl + 1) { - *deeper = 0; - } - numbering.get(lvl).copied() - } else if matches!(pp.bullet, Some(Bullet::AutoNum { .. })) { - // Preview the next number without consuming it: empty paragraphs do not advance - // numbering in the saved document or affect later populated paragraphs. - numbering.get(lvl).map(|n| n.saturating_add(1)) - } else { - if !para.is_empty() { - for n in numbering.iter_mut().skip(lvl) { - *n = 0; - } - } - None - }; - shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, upright_text)); + let auto_num = matches!(pp.bullet, Some(Bullet::AutoNum { .. })); + let number = next_number(&mut numbering, lvl, auto_num, para.is_empty()); + shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, upright)); } + shaped_paras +} + +/// A laid-out line with its placement data and, on a paragraph's first line, its bullet. +struct PlacedLine { + info: LineInfo, + place: LinePlace, + bullet: Option<(String, f64, f64)>, + /// Width of the line's text plus its start offset and right margin. + content_w: f64, +} + +/// Vertical flow state while paragraphs are stacked. +struct Flow { + y: f64, + prev_after: f64, + /// How far the last line's full pitch reaches past the text (exact spacing). + tail: f64, + lines: Vec, +} + +/// Measurements shared by the lines of one paragraph. +struct ParaEnv<'a> { + p: &'a ParaShaped, + g: &'a BoxGeom, + reduce: f64, + margin: f64, + indent: f64, + mr: f64, + bullet_w: f64, + first_x: f64, + rest_x: f64, + tabs: &'a [Tab], +} + +impl ParaEnv<'_> { + fn start_x(&self, li: usize) -> f64 { + if li == 0 { self.first_x } else { self.rest_x } + } +} + +pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout { + #[cfg(test)] + LAYOUT_PASSES.with(|n| n.set(n.get() + 1)); + let g = BoxGeom::new(shape, bp, opts.rect); + let shaped_paras = shape_paragraphs(ctx, shape, body, opts, scale, g.upright()); + let mut out = TextLayout { inner: g.inner, font_scale: scale, line_reduction: reduce, rotation: g.rotation, ..Default::default() }; + let first_last = bp.space_first_last.unwrap_or(false); + let mut flow = Flow { y: 0.0, prev_after: 0.0, tail: 0.0, lines: vec![] }; for (pi, p) in shaped_paras.iter().enumerate() { - let props = &p.props; - let margin = props.margin_left.unwrap_or(0.0).max(0.0); - let indent = props.indent.unwrap_or(0.0); - let mr = props.margin_right.unwrap_or(0.0).max(0.0); - let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0); - let first_x = if p.bullet.is_some() { - let bx = (margin + indent).max(0.0); - // A hanging bullet's text starts at the margin. When the hang reaches left of the box - // (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the - // hang: the first line's text starts at -indent (later lines stay at the margin). - let stop = margin.max(-indent); - if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } } - } else { - (margin + indent).max(0.0) - }; - let rest_x = margin; - let tabs = tabs_of(props); - let first_w = (col_w - first_x - mr).max(1.0); - let rest_w = (col_w - rest_x - mr).max(1.0); - let raw = break_lines(p, first_w, rest_w, wrap, &tabs, props.default_tab.unwrap_or(72.0), first_x, rest_x); - // The first paragraph's space before counts only when the body says so. - let before = if pi == 0 && !first_last { 0.0 } else { spacing_pts(props.space_before, p.lead_size) }; - y += before.max(0.0) + prev_after; - let first_line_idx = all_lines.len(); - for (li, rl) in raw.iter().enumerate() { - // Line metrics: max ascent/descent over the line's styles (or the lead style if empty). - let (mut asc, mut desc, mut size) = (0.0f64, 0.0f64, 0.0f64); - for c in p.cells.get(rl.start..rl.end.max(rl.start)).unwrap_or(&[]) { - if let (Some(st), Some(face)) = (p.styles.get(c.style), p.faces.get(c.face)) { - let sz = st.size; - // The run's own fonts set the line; a fallback face (emoji, a symbol or a - // script the run's fonts lack) does not move the baseline in PowerPoint. - let own = Arc::ptr_eq(face, &st.face) - || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.family.eq_ignore_ascii_case(f)); - let (a, d) = line_metrics(if own { face } else { &st.face }, sz); - asc = asc.max(a); - desc = desc.max(d); - size = size.max(sz); - } - } - if size == 0.0 { - // PowerPoint sizes an empty line by the mark that ends it: the `a:rPr` of the - // `a:br` that closes it, or `a:endParaRPr` for the paragraph's last line -- not - // by the paragraph's first run (#780). An empty *paragraph* already lands on - // `end_props` through `lead`, and keeps the same height here. - let brk = p.cells.get(rl.end).filter(|c| c.ch == '\u{b}').and_then(|c| p.styles.get(c.style)); - let (face, sz) = match brk { - Some(st) => (&st.face, st.size), - None if rl.end >= p.cells.len() => (&p.end_face, p.end_size), - // Anything else empty (a line of tabs alone) keeps the old measure. - None => (&p.lead_face, p.lead_size), - }; - let (a, d) = line_metrics(face, sz); - asc = a; - desc = d; - size = sz; - } - let natural = (asc + desc).max(size * 1.0); - let lh = match props.line_spacing { - // The reduction comes off the percentage (90 % less 20 % is 70 %), as PowerPoint - // applies `lnSpcReduction`. - Some(Spacing::Pct(f)) => natural * (f - reduce).max(f * 0.1).max(0.0), - Some(Spacing::Pts(v)) => v * (1.0 - reduce), - None => natural * (1.0 - reduce), - }; - let ratio = if natural > 0.0 { lh / natural } else { 1.0 }; - let top = y; - // Exact spacing (`spcPts`), as PowerPoint sets it: the baseline sits at 80 % of the - // line pitch, and the text ends at the last line's descent rather than a full pitch. - let exact = matches!(props.line_spacing, Some(Spacing::Pts(_))); - let baseline = if exact { top + lh * 0.8 } else { top + asc * ratio }; - tail = if exact { (lh * 0.2 - desc).max(-lh) } else { 0.0 }; - y += lh; - let x0 = if li == 0 { first_x } else { rest_x }; - let avail = (col_w - x0 - mr).max(0.0); - let n = rl.end.saturating_sub(rl.start); - let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); - // Logical advances; tabs measure from the start edge. - let mut adv = Vec::with_capacity(n); - let mut x = 0.0; - // A line that breaks at a soft hyphen ends with a visible hyphen. - let shy_end = li + 1 < raw.len() && line_cells.last().is_some_and(|c| c.ch == '\u{AD}'); - for (k, c) in line_cells.iter().enumerate() { - let a = if shy_end && k + 1 == n { - p.faces.get(c.face).zip(p.styles.get(c.style)).map(|(f, st)| measure_str(f, st.size, "-").0).unwrap_or(0.0) - } else if c.ch == '\t' { - tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k)) - } else { - c.adv - }; - adv.push(a); - x += a; - } - let width = x; - let align = props.align.unwrap_or(Align::Left); - let last_line = li + 1 == raw.len(); - let slack = (avail - width).max(0.0); - // Justification: kashida (justLow, Arabic), spaces, or every character. - let stretch = !last_line && wrap && slack > 0.0; - let mut kashida = vec![0.0; n]; - let mut filled = false; - if stretch && align == Align::JustLow { - let ops = kashida_opportunities(p, rl.start, rl.end); - if !ops.is_empty() { - let each = slack / ops.len() as f64; - for k in ops { - if let (Some(e), Some(a)) = (kashida.get_mut(k), adv.get_mut(k)) { - *e = each; - *a += each; - } - } - filled = true; - } - } - if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) { - let spaces = line_cells.iter().filter(|c| c.ch == ' ').count(); - if spaces > 0 { - for (a, c) in adv.iter_mut().zip(line_cells) { - if c.ch == ' ' { - *a += slack / spaces as f64; - } - } - filled = true; - } - } - if align == Align::Distributed && n > 1 { - for a in adv.iter_mut().take(n - 1) { - *a += slack / (n - 1) as f64; - } - filled = true; - } - let line_w: f64 = adv.iter().sum(); - // The line box, in column coordinates: the start edge is the left one for LTR and the - // right one (S = W - rIns) for RTL paragraphs. - let (box_l, box_r) = if p.rtl { (mr, col_w - x0) } else { (x0, col_w - mr) }; - let left = if filled { - box_l - } else { - match align { - Align::Left => box_l, - Align::Right => box_r - line_w, - Align::Center => box_l + (box_r - box_l - line_w) / 2.0, - Align::Distributed if n <= 1 => box_l + slack / 2.0, - // Justified lines that aren't stretched (the last one) sit at the start edge. - Align::Justify | Align::JustLow | Align::Distributed if p.rtl => box_r - line_w, - Align::Justify | Align::JustLow | Align::Distributed => box_l, - } - }; - // Distance from the box's start edge to the text (bullets move with centred or - // end-aligned text). - let content_shift = if p.rtl { box_r - (left + line_w) } else { left - box_l }; - // Rules L1 + L2: this line's visual order. - let chars: Vec = line_cells.iter().map(|c| c.ch).collect(); - let raw_levels: Vec = line_cells.iter().map(|c| c.level).collect(); - let levels = bidi::line_levels(&chars, &raw_levels, u8::from(p.rtl)); - let order = bidi::visual_order(&levels); - let mut edges = vec![(left, left); n]; - let mut vx = left; - for &k in &order { - let a = adv.get(k).copied().unwrap_or(0.0); - if let Some(e) = edges.get_mut(k) { - *e = (vx, vx + a); - } - vx += a; - } - let odd = |k: usize| levels.get(k).is_some_and(|l| l % 2 == 1); - let mut caret_x: Vec = (0..n).map(|k| edges.get(k).map(|e| if odd(k) { e.1 } else { e.0 }).unwrap_or(left)).collect(); - caret_x.push(match n.checked_sub(1) { - Some(k) => edges.get(k).map(|e| if odd(k) { e.0 } else { e.1 }).unwrap_or(left), - None if p.rtl => left + line_w, - None => left, - }); - let bullet = if li == 0 { - p.bullet.as_ref().map(|(s, _)| { - // Centred and end-aligned text carries its bullet along. - let end_aligned = if p.rtl { align == Align::Left } else { align == Align::Right }; - let shifted = align == Align::Center || end_aligned; - let b = if shifted && p.cells.is_empty() { - content_shift + x0 - bullet_w - } else { - (margin + indent).max(0.0) + if shifted { content_shift } else { 0.0 } - }; - // x_bullet = S - (marL + indent) for RTL: the bullet's start edge is its right. - let bx = if p.rtl { col_w - b - bullet_w } else { b }; - (s.clone(), bx, baseline) - }) - } else { - None - }; - out.content_width = out.content_width.max(width + x0 + mr); - let boundary: Vec = line_cells.iter().map(|c| c.boundary).collect(); - all_lines.push(( - LineInfo { - para: pi, - start: rl.start, - end: rl.end, - top, - baseline, - bottom: y, - caret_x, - column: 0, - rtl: p.rtl, - levels, - edges, - boundary, - extent: (left, left + line_w), - start_offset: x0, - }, - LinePlace { order, kashida }, - lh, - bullet, - )); - } - out.para_lines.push((first_line_idx, raw.len())); - prev_after = spacing_pts(props.space_after, p.lead_size).max(0.0); + place_paragraph(p, pi, &g, (reduce, first_last), &mut flow, &mut out); } // The text ends at the last line's descent under exact spacing; the last paragraph's space // after counts only when the body says so. - let total = (y - tail).max(0.0) + if first_last { prev_after } else { 0.0 }; + let total = (flow.y - flow.tail).max(0.0) + if first_last { flow.prev_after } else { 0.0 }; out.content_height = total; - // Where column `c` starts: from the left, or from the right with `rtlCol` (PowerPoint puts the - // first column on the right). - let rtl_cols = bp.rtl_col.unwrap_or(false) && ncols > 1; - let col_x = |c: usize| (if rtl_cols { ncols.saturating_sub(1 + c) } else { c }) as f64 * (col_w + col_gap); - // Columns: distribute lines by height. - let col_h = if ncols > 1 { inner.height() } else { f64::INFINITY }; - let mut col = 0usize; - let mut col_y0 = 0.0; - for (li, _, _, _) in all_lines.iter_mut() { - if ncols > 1 && li.bottom - col_y0 > col_h + 0.01 && col + 1 < ncols { - col += 1; - col_y0 = li.top; - } - li.column = col; - if col > 0 { - let dy = col_y0; - li.top -= dy; - li.baseline -= dy; - li.bottom -= dy; - } - let cx = col_x(col); - if cx != 0.0 { - li.shift_x(cx); - } - } - let used_h = if ncols > 1 { all_lines.iter().map(|(l, ..)| l.bottom).fold(0.0, f64::max) } else { total }; - out.overflow = used_h > inner.height() + 0.5 || (ncols > 1 && all_lines.last().is_some_and(|(l, ..)| l.bottom > inner.height() + 0.5)); - let anchor = bp.anchor.unwrap_or(Anchor::Top); - let dy = match anchor { - Anchor::Top => 0.0, - Anchor::Middle => (inner.height() - used_h) / 2.0, - // PowerPoint draws justified and distributed anchoring at the bottom. - Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h, - }; - // anchorCtr: centre the drawn text block on the box. Lines sit where their alignment put - // them, so the origin is set so the block's centre lands on the box's centre โ€” a left-edge - // offset alone pushed right-aligned lines out of the box (#736). Empty lines carry no ink - // and stay out of the block. Overflowing content centres symmetrically past both edges. - // Not applied to vertical text (there the offset would run along the wrong axis) or - // multi-column bodies (centring unimplemented for them). - let centre_block = bp.anchor_ctr.unwrap_or(false) && ncols == 1 && matches!(vert, TextDir::Horizontal); - let ox = if centre_block && !all_lines.is_empty() { - let ink = all_lines.iter().filter(|(li, ..)| li.end > li.start); - let (bmin, bmax) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (li, ..)| (a.min(li.extent.0), b.max(li.extent.1))); - if bmax >= bmin { inner.x0 + inner.width() / 2.0 - (bmin + bmax) / 2.0 } else { inner.x0 } - } else { - inner.x0 - }; - let oy = inner.y0 + dy; + let mut lines = flow.lines; + assign_columns(&mut lines, &g); + let used_h = if g.ncols > 1 { lines.iter().map(|l| l.info.bottom).fold(0.0, f64::max) } else { total }; + out.overflow = used_h > g.inner.height() + 0.5 || (g.ncols > 1 && lines.last().is_some_and(|l| l.info.bottom > g.inner.height() + 0.5)); + let (ox, oy) = text_origin(&lines, &g, bp, used_h); // Emit runs, in visual order. - for (line, place, _, bullet) in all_lines.iter_mut() { - line.top += oy; - line.baseline += oy; - line.bottom += oy; - line.shift_x(ox); - if lines_ltr { - // The line's place mirrored across the box: the first line ends up on the left. - let d = inner.y0 + inner.y1 - line.top - line.bottom; - line.top += d; - line.baseline += d; - line.bottom += d; - } - let Some(p) = shaped_paras.get(line.para) else { continue }; - if let Some((s, bx, _)) = bullet.take() - && let Some((_, st)) = p.bullet.as_ref() - { - let (_, g) = measure_str(&st.face, st.size, &s); - let run = GlyphRun { - face: st.face.clone(), - size: st.size, - color: st.color, - glyphs: g.into_iter().map(|(gid, x)| (gid, ox + bx + x + col_x(line.column), line.baseline)).collect(), - cells: vec![], - fake_bold: false, - fake_italic: false, - outline: None, - para: line.para, - chars: (0, 0), - link: false, - alpha: st.alpha, - gradient: None, - effects: None, - upright: false, - }; - if body.paragraphs.get(line.para).is_some_and(|p| p.is_empty()) && opts.prompt_color.is_none() { - out.empty_bullets.push(run); - } else { - out.runs.push(run); - } - } - // Group visually consecutive cells by (style, face). - let mut cur: Option = None; - let mut cur_key = (usize::MAX, usize::MAX, false, false); - for &k in &place.order { - let ci = line.start + k; - let Some(cell) = p.cells.get(ci) else { continue }; - let Some(st) = p.styles.get(cell.style) else { continue }; - let Some(&(cx, cx1)) = line.edges.get(k) else { continue }; - let base_y = line.baseline - st.baseline * st.size; - let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size }; - let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size }; - // Small capitals are drawn smaller than the capitals beside them: a run of their own. - let key = (cell.style, cell.face, st.caps == Caps::Small && cell.ch.is_lowercase(), cell.upright); - if key != cur_key { - if let Some(r) = cur.take() { - out.runs.push(r); - } - let Some(face) = p.faces.get(cell.face) else { continue }; - cur = Some(GlyphRun { - face: face.clone(), - size, - color: st.color, - glyphs: vec![], - cells: vec![], - fake_bold: st.fake_bold, - fake_italic: st.fake_italic, - outline: st.outline, - para: line.para, - chars: (ci, ci + 1), - link: st.link, - alpha: st.alpha, - gradient: st.gradient.clone(), - effects: st.effects, - upright: cell.upright, - }); - cur_key = key; - } - if let Some(r) = cur.as_mut() { - r.chars = (r.chars.0.min(ci), r.chars.1.max(ci + 1)); - if cell.ch == '\u{AD}' && cx1 - cx > 0.01 { - // The soft hyphen a line breaks at is drawn as a hyphen. - if let Some(f) = p.faces.get(cell.face) { - r.glyphs.push((f.glyph_for('-'), cx, base_y)); - r.cells.push((ci, cell.ch)); - } - } else if cell.head == ci && !cell.glyphs.is_empty() { - // The cluster's glyphs start at its leftmost cell; kashida (right-to-left - // only) fills the gap on the cluster's left with tatweel glyphs. - let cells_of = - (k..line.end.saturating_sub(line.start)).take_while(|&j| p.cells.get(line.start + j).is_some_and(|c| c.head == ci)); - let (mut cl, mut kash) = (cx, 0.0); - for j in cells_of { - cl = cl.min(line.edges.get(j).map(|e| e.0).unwrap_or(cx)); - kash += place.kashida.get(j).copied().unwrap_or(0.0); - } - if kash > 0.0 { - let face = p.faces.get(cell.face); - let tg = face.map(|f| f.glyph_for('\u{0640}')).unwrap_or(0); - let tw = face.map(|f| f.advance(tg) * size / f.upem.max(1.0)).unwrap_or(0.0); - if tg != 0 && tw > 0.0 { - let count = ((kash / tw).ceil() as usize).clamp(1, 256); - for i in 0..count { - r.glyphs.push((tg, cl + (i as f64 * tw).min((kash - tw).max(0.0)), base_y)); - r.cells.push((ci, cell.ch)); - } - } - } - for (gid, gx, gy) in &cell.glyphs { - r.glyphs.push((*gid, cl + kash + gx, base_y + gy)); - r.cells.push((ci, cell.ch)); - } - } - } - let adv = cx1 - cx; - if adv <= 0.0 { - continue; - } - if let Some(hl) = st.highlight { - out.decos.push(Deco { - rect: Rect::new(cx, line.top, cx1, line.bottom), - color: hl, - behind: true, - para: line.para, - line: DecoLine::Solid, - }); - } - if !cell.ch.is_whitespace() || st.underline.is_some() { - if let Some((uc, u)) = st.underline { - // At the underline position and thickness of the run's own font. - let k = st.size / st.face.upem.max(1.0); - let (p, t) = (st.face.underline.0 * k, (st.face.underline.1 * k).max(0.5)); - for (y0, y1, kind) in u.boxes(p, t) { - out.decos.push(Deco { - rect: Rect::new(cx, line.baseline + y0, cx1, line.baseline + y1), - color: uc, - behind: false, - para: line.para, - line: kind, - }); - } - } - if let Some(sk) = st.strike { - // At the strikeout position and thickness of the run's own font. - let k = st.size / st.face.upem.max(1.0); - let t = (st.face.strikeout.1 * k).max(0.5); - let sy = line.baseline - st.face.strikeout.0 * k; - out.decos.push(Deco { - rect: Rect::new(cx, sy, cx1, sy + t), - color: st.color, - behind: false, - para: line.para, - line: DecoLine::Solid, - }); - if sk == Strike::Double { - out.decos.push(Deco { - rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t), - color: st.color, - behind: false, - para: line.para, - line: DecoLine::Solid, - }); - } - } - } - } - if let Some(r) = cur.take() { - out.runs.push(r); + for mut pl in lines { + position_line(&mut pl.info, ox, oy, &g); + if let Some(p) = shaped_paras.get(pl.info.para) { + let to_empty = body.paragraphs.get(pl.info.para).is_some_and(|p| p.is_empty()) && opts.prompt_color.is_none(); + emit_bullet(&mut out, p, &pl, ox + g.col_x(pl.info.column), to_empty); + emit_line_runs(&mut out, p, &pl.info, &pl.place); } + out.lines.push(pl.info); } out.boundaries = shaped_paras .iter() @@ -589,12 +248,538 @@ pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &Body b }) .collect(); - out.lines = all_lines.into_iter().map(|(l, ..)| l).collect(); // Merge adjacent decorations of the same colour on the same line. out.decos = merge_decos(std::mem::take(&mut out.decos)); out } +/// Where the first line's text starts: past the bullet when there is one. +fn first_line_start(has_bullet: bool, margin: f64, indent: f64, bullet_w: f64) -> f64 { + if !has_bullet { + return (margin + indent).max(0.0); + } + let bx = (margin + indent).max(0.0); + // A hanging bullet's text starts at the margin. When the hang reaches left of the box + // (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the + // hang: the first line's text starts at -indent (later lines stay at the margin). + let stop = margin.max(-indent); + if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } } +} + +/// Breaks one paragraph into lines and stacks them onto `flow`. `spacing` is +/// (line spacing reduction, whether the first paragraph's space before counts). +fn place_paragraph(p: &ParaShaped, pi: usize, g: &BoxGeom, spacing: (f64, bool), flow: &mut Flow, out: &mut TextLayout) { + let (reduce, first_last) = spacing; + let props = &p.props; + let margin = props.margin_left.unwrap_or(0.0).max(0.0); + let indent = props.indent.unwrap_or(0.0); + let mr = props.margin_right.unwrap_or(0.0).max(0.0); + let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0); + let first_x = first_line_start(p.bullet.is_some(), margin, indent, bullet_w); + let rest_x = margin; + let tabs = tabs_of(props); + let first_w = (g.col_w - first_x - mr).max(1.0); + let rest_w = (g.col_w - rest_x - mr).max(1.0); + let raw = break_lines( + p, + &BreakParams { + first_w, + rest_w, + wrap: g.wrap, + tab_stops: &tabs, + default_tab: props.default_tab.unwrap_or(72.0), + x0_first: first_x, + x0_rest: rest_x, + }, + ); + // The first paragraph's space before counts only when the body says so. + let before = if pi == 0 && !first_last { 0.0 } else { spacing_pts(props.space_before, p.lead_size) }; + flow.y += before.max(0.0) + flow.prev_after; + let first_line_idx = flow.lines.len(); + let env = ParaEnv { p, g, reduce, margin, indent, mr, bullet_w, first_x, rest_x, tabs: &tabs }; + for (li, rl) in raw.iter().enumerate() { + let is_last = li + 1 == raw.len(); + let placed = place_line(&env, pi, rl, li, is_last, flow); + out.content_width = out.content_width.max(placed.content_w); + flow.lines.push(placed); + } + out.para_lines.push((first_line_idx, raw.len())); + flow.prev_after = spacing_pts(props.space_after, p.lead_size).max(0.0); +} + +/// Max ascent / descent / size over the line's styles (or the lead style if empty). +fn line_extent(p: &ParaShaped, rl: &RawLine) -> (f64, f64, f64) { + let (mut asc, mut desc, mut size) = (0.0f64, 0.0f64, 0.0f64); + for c in p.cells.get(rl.start..rl.end.max(rl.start)).unwrap_or(&[]) { + if let (Some(st), Some(face)) = (p.styles.get(c.style), p.faces.get(c.face)) { + let sz = st.size; + // The run's own fonts set the line; a fallback face (emoji, a symbol or a + // script the run's fonts lack) does not move the baseline in PowerPoint. + let own = Arc::ptr_eq(face, &st.face) + || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.family.eq_ignore_ascii_case(f)); + let (a, d) = line_metrics(if own { face } else { &st.face }, sz); + asc = asc.max(a); + desc = desc.max(d); + size = size.max(sz); + } + } + if size == 0.0 { + // PowerPoint sizes an empty line by the mark that ends it: the `a:rPr` of the + // `a:br` that closes it, or `a:endParaRPr` for the paragraph's last line -- not + // by the paragraph's first run (#780). An empty *paragraph* already lands on + // `end_props` through `lead`, and keeps the same height here. + let brk = p.cells.get(rl.end).filter(|c| c.ch == '\u{b}').and_then(|c| p.styles.get(c.style)); + let (face, sz) = match brk { + Some(st) => (&st.face, st.size), + None if rl.end >= p.cells.len() => (&p.end_face, p.end_size), + // Anything else empty (a line of tabs alone) keeps the old measure. + None => (&p.lead_face, p.lead_size), + }; + let (a, d) = line_metrics(face, sz); + return (a, d, sz); + } + (asc, desc, size) +} + +/// Logical advance of each cell on the line (tabs measure from the start edge) and their sum. +fn line_advances(env: &ParaEnv, rl: &RawLine, is_last: bool, x0: f64) -> (Vec, f64) { + let p = env.p; + let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); + let n = rl.end.saturating_sub(rl.start); + let mut adv = Vec::with_capacity(n); + let mut x = 0.0; + // A line that breaks at a soft hyphen ends with a visible hyphen. + let shy_end = !is_last && line_cells.last().is_some_and(|c| c.ch == '\u{AD}'); + for (k, c) in line_cells.iter().enumerate() { + let a = if shy_end && k + 1 == n { + p.faces.get(c.face).zip(p.styles.get(c.style)).map(|(f, st)| measure_str(f, st.size, "-").0).unwrap_or(0.0) + } else if c.ch == '\t' { + tab_advance(x0 + x, env.tabs, p.props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k)) + } else { + c.adv + }; + adv.push(a); + x += a; + } + (adv, x) +} + +/// Justification: kashida (justLow, Arabic), spaces, or every character. Widens `adv` and returns +/// the kashida widths and whether the line was filled to the box edges. +fn justify_line(p: &ParaShaped, rl: &RawLine, adv: &mut [f64], align: Align, slack: f64, stretch: bool) -> (Vec, bool) { + let n = adv.len(); + let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); + let mut kashida = vec![0.0; n]; + let mut filled = false; + if stretch && align == Align::JustLow { + let ops = kashida_opportunities(p, rl.start, rl.end); + if !ops.is_empty() { + let each = slack / ops.len() as f64; + for k in ops { + if let (Some(e), Some(a)) = (kashida.get_mut(k), adv.get_mut(k)) { + *e = each; + *a += each; + } + } + filled = true; + } + } + if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) { + let spaces = line_cells.iter().filter(|c| c.ch == ' ').count(); + if spaces > 0 { + for (a, c) in adv.iter_mut().zip(line_cells) { + if c.ch == ' ' { + *a += slack / spaces as f64; + } + } + filled = true; + } + } + if align == Align::Distributed && n > 1 { + for a in adv.iter_mut().take(n - 1) { + *a += slack / (n - 1) as f64; + } + filled = true; + } + (kashida, filled) +} + +/// Left edge of the line's text inside its box `(box_l, box_r)`. +fn line_left(align: Align, filled: bool, rtl: bool, bx: (f64, f64), line_w: f64, slack: f64, n: usize) -> f64 { + let (box_l, box_r) = bx; + if filled { + return box_l; + } + match align { + Align::Left => box_l, + Align::Right => box_r - line_w, + Align::Center => box_l + (box_r - box_l - line_w) / 2.0, + Align::Distributed if n <= 1 => box_l + slack / 2.0, + // Justified lines that aren't stretched (the last one) sit at the start edge. + Align::Justify | Align::JustLow | Align::Distributed if rtl => box_r - line_w, + Align::Justify | Align::JustLow | Align::Distributed => box_l, + } +} + +/// Visual boxes of each cell and the caret x for every boundary, from the visual order and widths. +fn edges_and_carets(order: &[usize], levels: &[u8], adv: &[f64], left: f64, rtl: bool, line_w: f64) -> (Vec<(f64, f64)>, Vec) { + let n = adv.len(); + let mut edges = vec![(left, left); n]; + let mut vx = left; + for &k in order { + let a = adv.get(k).copied().unwrap_or(0.0); + if let Some(e) = edges.get_mut(k) { + *e = (vx, vx + a); + } + vx += a; + } + let odd = |k: usize| levels.get(k).is_some_and(|l| l % 2 == 1); + let mut caret_x: Vec = (0..n).map(|k| edges.get(k).map(|e| if odd(k) { e.1 } else { e.0 }).unwrap_or(left)).collect(); + caret_x.push(match n.checked_sub(1) { + Some(k) => edges.get(k).map(|e| if odd(k) { e.0 } else { e.1 }).unwrap_or(left), + None if rtl => left + line_w, + None => left, + }); + (edges, caret_x) +} + +/// Bullet text, x and baseline for a paragraph's first line. +fn line_bullet(env: &ParaEnv, align: Align, content_shift: f64, baseline: f64) -> Option<(String, f64, f64)> { + let p = env.p; + p.bullet.as_ref().map(|(s, _)| { + // Centred and end-aligned text carries its bullet along. + let end_aligned = if p.rtl { align == Align::Left } else { align == Align::Right }; + let shifted = align == Align::Center || end_aligned; + let b = if shifted && p.cells.is_empty() { + content_shift + env.first_x - env.bullet_w + } else { + (env.margin + env.indent).max(0.0) + if shifted { content_shift } else { 0.0 } + }; + // x_bullet = S - (marL + indent) for RTL: the bullet's start edge is its right. + let bx = if p.rtl { env.g.col_w - b - env.bullet_w } else { b }; + (s.clone(), bx, baseline) + }) +} + +/// Lays out line `li` (`rl`) of a paragraph at the flow's current height and advances the flow. +fn place_line(env: &ParaEnv, pi: usize, rl: &RawLine, li: usize, is_last: bool, flow: &mut Flow) -> PlacedLine { + let (p, g) = (env.p, env.g); + let props = &p.props; + let (asc, desc, size) = line_extent(p, rl); + let natural = (asc + desc).max(size * 1.0); + let lh = match props.line_spacing { + // The reduction comes off the percentage (90 % less 20 % is 70 %), as PowerPoint + // applies `lnSpcReduction`. + Some(Spacing::Pct(f)) => natural * (f - env.reduce).max(f * 0.1).max(0.0), + Some(Spacing::Pts(v)) => v * (1.0 - env.reduce), + None => natural * (1.0 - env.reduce), + }; + let ratio = if natural > 0.0 { lh / natural } else { 1.0 }; + let top = flow.y; + // Exact spacing (`spcPts`), as PowerPoint sets it: the baseline sits at 80 % of the + // line pitch, and the text ends at the last line's descent rather than a full pitch. + let exact = matches!(props.line_spacing, Some(Spacing::Pts(_))); + let baseline = if exact { top + lh * 0.8 } else { top + asc * ratio }; + flow.tail = if exact { (lh * 0.2 - desc).max(-lh) } else { 0.0 }; + flow.y += lh; + let x0 = env.start_x(li); + let avail = (g.col_w - x0 - env.mr).max(0.0); + let n = rl.end.saturating_sub(rl.start); + let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); + let (mut adv, width) = line_advances(env, rl, is_last, x0); + let align = props.align.unwrap_or(Align::Left); + let slack = (avail - width).max(0.0); + let stretch = !is_last && g.wrap && slack > 0.0; + let (kashida, filled) = justify_line(p, rl, &mut adv, align, slack, stretch); + let line_w: f64 = adv.iter().sum(); + // The line box, in column coordinates: the start edge is the left one for LTR and the + // right one (S = W - rIns) for RTL paragraphs. + let (box_l, box_r) = if p.rtl { (env.mr, g.col_w - x0) } else { (x0, g.col_w - env.mr) }; + let left = line_left(align, filled, p.rtl, (box_l, box_r), line_w, slack, n); + // Distance from the box's start edge to the text (bullets move with centred or + // end-aligned text). + let content_shift = if p.rtl { box_r - (left + line_w) } else { left - box_l }; + // Rules L1 + L2: this line's visual order. + let chars: Vec = line_cells.iter().map(|c| c.ch).collect(); + let raw_levels: Vec = line_cells.iter().map(|c| c.level).collect(); + let levels = bidi::line_levels(&chars, &raw_levels, u8::from(p.rtl)); + let order = bidi::visual_order(&levels); + let (edges, caret_x) = edges_and_carets(&order, &levels, &adv, left, p.rtl, line_w); + let bullet = if li == 0 { line_bullet(env, align, content_shift, baseline) } else { None }; + let boundary: Vec = line_cells.iter().map(|c| c.boundary).collect(); + PlacedLine { + info: LineInfo { + para: pi, + start: rl.start, + end: rl.end, + top, + baseline, + bottom: flow.y, + caret_x, + column: 0, + rtl: p.rtl, + levels, + edges, + boundary, + extent: (left, left + line_w), + start_offset: x0, + }, + place: LinePlace { order, kashida }, + bullet, + content_w: width + x0 + env.mr, + } +} + +/// Columns: distribute lines by height. +fn assign_columns(lines: &mut [PlacedLine], g: &BoxGeom) { + let col_h = if g.ncols > 1 { g.inner.height() } else { f64::INFINITY }; + let mut col = 0usize; + let mut col_y0 = 0.0; + for pl in lines.iter_mut() { + let li = &mut pl.info; + if g.ncols > 1 && li.bottom - col_y0 > col_h + 0.01 && col + 1 < g.ncols { + col += 1; + col_y0 = li.top; + } + li.column = col; + if col > 0 { + let dy = col_y0; + li.top -= dy; + li.baseline -= dy; + li.bottom -= dy; + } + let cx = g.col_x(col); + if cx != 0.0 { + li.shift_x(cx); + } + } +} + +/// Origin (x, y) of the text block inside the box, from the anchor and `anchorCtr`. +fn text_origin(lines: &[PlacedLine], g: &BoxGeom, bp: &BodyProps, used_h: f64) -> (f64, f64) { + let inner = g.inner; + let dy = match bp.anchor.unwrap_or(Anchor::Top) { + Anchor::Top => 0.0, + Anchor::Middle => (inner.height() - used_h) / 2.0, + // PowerPoint draws justified and distributed anchoring at the bottom. + Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h, + }; + // anchorCtr: centre the drawn text block on the box. Lines sit where their alignment put + // them, so the origin is set so the block's centre lands on the box's centre โ€” a left-edge + // offset alone pushed right-aligned lines out of the box (#736). Empty lines carry no ink + // and stay out of the block. Overflowing content centres symmetrically past both edges. + // Not applied to vertical text (there the offset would run along the wrong axis) or + // multi-column bodies (centring unimplemented for them). + let centre_block = bp.anchor_ctr.unwrap_or(false) && g.ncols == 1 && matches!(g.vert, TextDir::Horizontal); + let ox = if centre_block && !lines.is_empty() { + let ink = lines.iter().map(|l| &l.info).filter(|li| li.end > li.start); + let (bmin, bmax) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), li| (a.min(li.extent.0), b.max(li.extent.1))); + if bmax >= bmin { inner.x0 + inner.width() / 2.0 - (bmin + bmax) / 2.0 } else { inner.x0 } + } else { + inner.x0 + }; + (ox, inner.y0 + dy) +} + +/// Moves a line from column coordinates into shape-local ones. +fn position_line(line: &mut LineInfo, ox: f64, oy: f64, g: &BoxGeom) { + line.top += oy; + line.baseline += oy; + line.bottom += oy; + line.shift_x(ox); + if g.lines_ltr() { + // The line's place mirrored across the box: the first line ends up on the left. + let d = g.inner.y0 + g.inner.y1 - line.top - line.bottom; + line.top += d; + line.baseline += d; + line.bottom += d; + } +} + +/// Emits the bullet glyph run of a line's paragraph, if the line carries one. `shift_x` is the +/// text block's x origin plus the line's column offset. +fn emit_bullet(out: &mut TextLayout, p: &ParaShaped, pl: &PlacedLine, shift_x: f64, to_empty: bool) { + let line = &pl.info; + let Some((s, bx, _)) = pl.bullet.as_ref() else { return }; + let Some((_, st)) = p.bullet.as_ref() else { return }; + let (_, g) = measure_str(&st.face, st.size, s); + let run = GlyphRun { + face: st.face.clone(), + size: st.size, + color: st.color, + glyphs: g.into_iter().map(|(gid, x)| (gid, shift_x + bx + x, line.baseline)).collect(), + cells: vec![], + fake_bold: false, + fake_italic: false, + outline: None, + para: line.para, + chars: (0, 0), + link: false, + alpha: st.alpha, + gradient: None, + effects: None, + upright: false, + }; + if to_empty { + out.empty_bullets.push(run); + } else { + out.runs.push(run); + } +} + +/// Position of one cell on its line: index in the line, in the paragraph, its box, and the glyph +/// baseline and size. +struct CellPos { + k: usize, + ci: usize, + cx: f64, + cx1: f64, + base_y: f64, + size: f64, +} + +/// Starts a glyph run for `cell` drawn in style `st`. +fn new_run(cell: &Cell, st: &Style, face: &Arc, para: usize, pos: &CellPos) -> GlyphRun { + GlyphRun { + face: face.clone(), + size: pos.size, + color: st.color, + glyphs: vec![], + cells: vec![], + fake_bold: st.fake_bold, + fake_italic: st.fake_italic, + outline: st.outline, + para, + chars: (pos.ci, pos.ci + 1), + link: st.link, + alpha: st.alpha, + gradient: st.gradient.clone(), + effects: st.effects, + upright: cell.upright, + } +} + +/// Emits one line's glyph runs and decorations, in visual order. +fn emit_line_runs(out: &mut TextLayout, p: &ParaShaped, line: &LineInfo, place: &LinePlace) { + // Group visually consecutive cells by (style, face). + let mut cur: Option = None; + let mut cur_key = (usize::MAX, usize::MAX, false, false); + for &k in &place.order { + let ci = line.start + k; + let Some(cell) = p.cells.get(ci) else { continue }; + let Some(st) = p.styles.get(cell.style) else { continue }; + let Some(&(cx, cx1)) = line.edges.get(k) else { continue }; + let base_y = line.baseline - st.baseline * st.size; + let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size }; + let small = st.caps == Caps::Small && cell.ch.is_lowercase(); + let size = if small { size * 0.8 } else { size }; + let pos = CellPos { k, ci, cx, cx1, base_y, size }; + // Small capitals are drawn smaller than the capitals beside them: a run of their own. + let key = (cell.style, cell.face, small, cell.upright); + if key != cur_key { + if let Some(r) = cur.take() { + out.runs.push(r); + } + let Some(face) = p.faces.get(cell.face) else { continue }; + cur = Some(new_run(cell, st, face, line.para, &pos)); + cur_key = key; + } + if let Some(r) = cur.as_mut() { + push_cell_glyphs(r, p, line, place, cell, &pos); + } + if cx1 - cx > 0.0 { + push_cell_decos(out, cell, st, line, &pos); + } + } + if let Some(r) = cur.take() { + out.runs.push(r); + } +} + +/// Appends the glyphs a cell draws to the run `r`. +fn push_cell_glyphs(r: &mut GlyphRun, p: &ParaShaped, line: &LineInfo, place: &LinePlace, cell: &Cell, pos: &CellPos) { + let CellPos { k, ci, cx, cx1, base_y, .. } = *pos; + r.chars = (r.chars.0.min(ci), r.chars.1.max(ci + 1)); + if cell.ch == '\u{AD}' && cx1 - cx > 0.01 { + // The soft hyphen a line breaks at is drawn as a hyphen. + if let Some(f) = p.faces.get(cell.face) { + r.glyphs.push((f.glyph_for('-'), cx, base_y)); + r.cells.push((ci, cell.ch)); + } + } else if cell.head == ci && !cell.glyphs.is_empty() { + // The cluster's glyphs start at its leftmost cell; kashida (right-to-left + // only) fills the gap on the cluster's left with tatweel glyphs. + let cells_of = (k..line.end.saturating_sub(line.start)).take_while(|&j| p.cells.get(line.start + j).is_some_and(|c| c.head == ci)); + let (mut cl, mut kash) = (cx, 0.0); + for j in cells_of { + cl = cl.min(line.edges.get(j).map(|e| e.0).unwrap_or(cx)); + kash += place.kashida.get(j).copied().unwrap_or(0.0); + } + if kash > 0.0 { + push_tatweel(r, p.faces.get(cell.face), cell, (cl, kash), pos); + } + for (gid, gx, gy) in &cell.glyphs { + r.glyphs.push((*gid, cl + kash + gx, base_y + gy)); + r.cells.push((ci, cell.ch)); + } + } +} + +/// Fills `kash` points left of a cluster at `cl` with tatweel glyphs. +fn push_tatweel(r: &mut GlyphRun, face: Option<&Arc>, cell: &Cell, fill: (f64, f64), pos: &CellPos) { + let (cl, kash) = fill; + let tg = face.map(|f| f.glyph_for('\u{0640}')).unwrap_or(0); + let tw = face.map(|f| f.advance(tg) * pos.size / f.upem.max(1.0)).unwrap_or(0.0); + if tg != 0 && tw > 0.0 { + let count = ((kash / tw).ceil() as usize).clamp(1, 256); + for i in 0..count { + r.glyphs.push((tg, cl + (i as f64 * tw).min((kash - tw).max(0.0)), pos.base_y)); + r.cells.push((pos.ci, cell.ch)); + } + } +} + +/// Highlight, underline and strikethrough boxes of a cell. +fn push_cell_decos(out: &mut TextLayout, cell: &Cell, st: &Style, line: &LineInfo, pos: &CellPos) { + let (cx, cx1) = (pos.cx, pos.cx1); + if let Some(hl) = st.highlight { + out.decos.push(Deco { rect: Rect::new(cx, line.top, cx1, line.bottom), color: hl, behind: true, para: line.para, line: DecoLine::Solid }); + } + if cell.ch.is_whitespace() && st.underline.is_none() { + return; + } + if let Some((uc, u)) = st.underline { + // At the underline position and thickness of the run's own font. + let k = st.size / st.face.upem.max(1.0); + let (p, t) = (st.face.underline.0 * k, (st.face.underline.1 * k).max(0.5)); + for (y0, y1, kind) in u.boxes(p, t) { + out.decos.push(Deco { + rect: Rect::new(cx, line.baseline + y0, cx1, line.baseline + y1), + color: uc, + behind: false, + para: line.para, + line: kind, + }); + } + } + if let Some(sk) = st.strike { + // At the strikeout position and thickness of the run's own font. + let k = st.size / st.face.upem.max(1.0); + let t = (st.face.strikeout.1 * k).max(0.5); + let sy = line.baseline - st.face.strikeout.0 * k; + out.decos.push(Deco { rect: Rect::new(cx, sy, cx1, sy + t), color: st.color, behind: false, para: line.para, line: DecoLine::Solid }); + if sk == Strike::Double { + out.decos.push(Deco { + rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t), + color: st.color, + behind: false, + para: line.para, + line: DecoLine::Solid, + }); + } + } +} + fn merge_decos(v: Vec) -> Vec { let mut out: Vec = Vec::with_capacity(v.len()); for d in v { diff --git a/crates/text/src/run.rs b/crates/text/src/run.rs index 5b5cb963..d7e9dbee 100644 --- a/crates/text/src/run.rs +++ b/crates/text/src/run.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, FontFace, Point, Rect, Rgba}; /// A run of glyphs in one face, size and colour. #[derive(Clone)] diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 11706e6f..03dc8418 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -2,7 +2,10 @@ use super::autonum::autonum; use super::run::{RunEffects, TextShadow}; use super::style::{Cell, Opts, ParaShaped, Style, face_chain, make_style}; use super::symbol::{covers_symbol, map_symbol_bullet, symbol_font_char, symbol_map_char}; -use super::*; +use super::{ + Arc, Bullet, Caps, Ctx, Fill, FontDb, FontFace, FontSlot, HashMap, ParaProps, RunKind, RunProps, ScriptTag, Shape, ShapeParams, TextBody, + UnicodeSegmentation, bidi, resolve, script, +}; #[allow(clippy::too_many_arguments)] pub(crate) fn shape_para( diff --git a/crates/text/src/style.rs b/crates/text/src/style.rs index be8d7e7b..e1289c52 100644 --- a/crates/text/src/style.rs +++ b/crates/text/src/style.rs @@ -1,6 +1,6 @@ use super::run::{RunEffects, Underline}; use super::shape::run_effects; -use super::*; +use super::{Arc, Caps, Ctx, Fill, FontDb, FontFace, FontSlot, ParaProps, Rect, Rgba, RunProps, ScriptTag, Spacing, Strike, resolve}; /// Values for fields (`slidenum`, `datetimeโ€ฆ`, `footer`). `None` keeps the field's saved text. pub trait Fields { diff --git a/crates/text/src/symbol.rs b/crates/text/src/symbol.rs index 214470a6..e79894c3 100644 --- a/crates/text/src/symbol.rs +++ b/crates/text/src/symbol.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{FontFace, UnicodeSegmentation}; /// Is `family` a symbol font (characters at U+F000 + code)? pub(crate) fn is_symbol_font(family: &str) -> bool { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 2cf3c2f1..88b9f770 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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"] } +craft-ui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c4b618d4..8496d0ed 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -2,7 +2,13 @@ use super::clock::{monotonic_secs, now_ms}; use super::ime_hooks::ime_in_ui_pass_key; use super::services::{InboxEvent, Services}; use super::state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp, LocatedPlace, RECENT_MAX, UiState, ViewMode}; -use super::*; +#[cfg(not(target_arch = "wasm32"))] +use super::updates_ui; +use super::{ + AnimPreview, ControlRequest, FileTarget, HashMap, ImeCancelled, ImeComposition, ImeResetPhase, ImeTerminalPending, InputTarget, Mods, + PendingFile, Perf, Receiver, Session, SlideApp, Target, UI_COMMANDS, UiRequest, Value, backstage, canvas, capture, dialogs, doc_window, + eyedropper, json, media, menus, notes, panes, preview, qat, ribbon, selpane, show, sorter, status, textures, theme, thumbs, toast, uifonts, +}; mod clipboard; mod commands; diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 10778afc..19fd48bf 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SlideApp, Value, json}; /// Text put on the system clipboard when the copied objects (a rectangle, a picture, slidesโ€ฆ) /// carry no text. egui-winit drops Ctrl+V when the system clipboard is empty, so without it the diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 4592d6f1..060d2138 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,4 +1,6 @@ -use super::*; +#[cfg(not(target_arch = "wasm32"))] +use super::updates_ui; +use super::{SlideApp, UI_COMMANDS, Value, ViewMode, backstage, dialogs, doc_window, json, media, qat, theme}; fn grid_options_touched(p: &Value) -> bool { ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) @@ -272,7 +274,7 @@ impl SlideApp { self.dialog = Some(dialogs::Dialog::new(d)); } "app.links" => { - use deckcraft_engine::links::*; + use deckcraft_engine::links::{APP_PAGE, APPS, DISCORD, GITHUB, WEBSITE}; return Ok(json!({"discord": DISCORD, "website": WEBSITE, "apps": APPS, "appPage": APP_PAGE, "github": GITHUB})); } _ => return Err(format!("unknown UI command `{id}`")), diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index f6d73e23..81e6c479 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SlideApp, json}; use crate::control; impl SlideApp { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index c035fcbf..0f3c944f 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + FilePlace, FilePurpose, FileTarget, InboxEvent, LocatedPlace, PendingFile, RECENT_MAX, SlideApp, Target, UiRequest, Value, ViewMode, dialogs, + json, media, +}; fn purpose_of(purpose: &str) -> FilePurpose { match purpose { @@ -446,7 +449,7 @@ impl SlideApp { let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor }; // The command resolved `shape` on the editor's slide, also while a show runs. if let Some((uid, doc, slide)) = self.session.active().map(|d| (d.uid, d.doc.clone(), d.selection.slide)) { - self.media.request(uid, &doc, slide, &action, shape, ms, owner); + self.media.request(uid, &doc, slide, &action, (shape, ms), owner); } } } diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 7a458810..0da3896b 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SlideApp, json, media, monotonic_secs, theme}; impl SlideApp { /// Style, fonts and zoom setup. Runs in a real UI pass, not from `run_logic`, because diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs index a4bbfee0..3632b368 100644 --- a/crates/ui-egui/src/app/ime.rs +++ b/crates/ui-egui/src/app/ime.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + ImeCancelled, ImeComposition, ImeDescriptorKey, ImeDestination, ImeResetPhase, ImeTerminalPending, ImeTerminalStamp, SlideApp, Target, Value, + ViewMode, ime_in_ui_pass_key, +}; impl SlideApp { /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 463fbb8c..c5060171 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{InputTarget, SlideApp, show}; impl SlideApp { /// What a queued synthetic event is meant for. The control protocol hands out window diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 2d821856..d6903e82 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -1,5 +1,5 @@ use super::clipboard::OBJECTS_MARKER; -use super::*; +use super::{Mods, SlideApp, Value, backstage, json, menus}; impl SlideApp { /// The app shortcuts that still make sense on the empty File/start screen: create, open, diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index b2b8145f..73e765ff 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AnimPreview, SlideApp, Value, ViewMode, json, media, preview, show}; impl SlideApp { /// Whether โŒ˜A should act on the slide list: always in the sorter, in Normal view once the diff --git a/crates/ui-egui/src/backstage/files.rs b/crates/ui-egui/src/backstage/files.rs index 58bdf94b..4b7cec66 100644 --- a/crates/ui-egui/src/backstage/files.rs +++ b/crates/ui-egui/src/backstage/files.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, Backstage, CONTENT_W, Color32, CornerRadius, Icon, Outcome, Page, Rect, RichText, Sense, SlideApp, Stroke, StrokeKind, Tokens, Ui, Value, + arrow_link, dim, heading, icons, json, pos2, search, theme, vec2, +}; /// A new-presentation tile: a theme preview drawn in code (or a blank slide) over its name. fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: bool) -> egui::Response { diff --git a/crates/ui-egui/src/backstage/info.rs b/crates/ui-egui/src/backstage/info.rs index e1e23027..690e5524 100644 --- a/crates/ui-egui/src/backstage/info.rs +++ b/crates/ui-egui/src/backstage/info.rs @@ -1,5 +1,5 @@ use super::files::task; -use super::*; +use super::{Backstage, Icon, Outcome, Page, RichText, Sense, SlideApp, Tokens, Ui, Value, dim, heading, json, theme, vec2}; /// Save As and Change File Type: (id for [`SlideApp::export_as`], label, detail). pub const SAVE_TYPES: [(&str, &str, &str); 7] = [ @@ -154,7 +154,7 @@ pub(super) fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: ui.add_space(14.0); let current = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type).map_or(SAVE_TYPES[0].1, |s| s.1); ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("backstage_save_type") + craft_ui::ComboBox::from_id_salt("backstage_save_type") .width(340.0) .selected_text(current) .show_ui(ui, |ui| { @@ -198,7 +198,7 @@ pub(super) fn account(app: &mut SlideApp, ui: &mut Ui) { ui.set_width(340.0); heading(ui, "Appearance"); let dark = app.ui.brightness == theme::Brightness::Dark; - egui::ComboBox::from_id_salt("backstage_appearance") + craft_ui::ComboBox::from_id_salt("backstage_appearance") .width(260.0) .selected_text(if dark { "Dark" } else { "Light" }) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/backstage/print.rs b/crates/ui-egui/src/backstage/print.rs index 9ebf15c5..612ecaf7 100644 --- a/crates/ui-egui/src/backstage/print.rs +++ b/crates/ui-egui/src/backstage/print.rs @@ -1,6 +1,9 @@ use super::files::{place_row, square_button}; use super::info::SAVE_TYPES; -use super::*; +use super::{ + Align2, Backstage, Color32, CornerRadius, ExportTask, Icon, PrintScope, Rect, RichText, Sense, SlideApp, Stroke, StrokeKind, Tokens, Ui, Value, + bullets, dim, heading, icon_button, json, pos2, theme, vec2, +}; /// The slides the Print page prints, in order (0-based). pub fn print_slides(doc: &deckcraft_model::Presentation, bs: &Backstage, current: usize) -> Result, String> { @@ -93,7 +96,7 @@ pub(super) fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { PrintScope::Current => "Print Current Slide", PrintScope::Custom => "Custom Range", }; - let combo = egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| { for (s, label) in [(PrintScope::All, "Print All Slides"), (PrintScope::Current, "Print Current Slide"), (PrintScope::Custom, "Custom Range")] { @@ -116,7 +119,7 @@ pub(super) fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { }); ui.add_space(4.0); let layout = LAYOUTS.iter().find(|l| l.0 == bs.print_layout).map_or("Full Page Slides", |l| l.1); - let combo = egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| { for (id, label) in LAYOUTS { if ui.selectable_label(bs.print_layout == id, label).clicked() { bs.print_layout = id; @@ -130,7 +133,7 @@ pub(super) fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { // for notes pages and handouts. ui.add_enabled_ui(bs.print_layout != "slides", |ui| { let paper = deckcraft_engine::cmd::file::PAPERS.iter().find(|p| p.0 == bs.print_paper).map_or("Letter", |p| p.1); - egui::ComboBox::from_id_salt("backstage_print_paper") + craft_ui::ComboBox::from_id_salt("backstage_print_paper") .width(240.0) .selected_text(paper) .show_ui(ui, |ui| { @@ -144,7 +147,7 @@ pub(super) fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Paper size")); ui.add_space(4.0); let orient = if bs.print_landscape { "Landscape Orientation" } else { "Portrait Orientation" }; - egui::ComboBox::from_id_salt("backstage_print_orientation") + craft_ui::ComboBox::from_id_salt("backstage_print_orientation") .width(240.0) .selected_text(orient) .show_ui(ui, |ui| { @@ -317,7 +320,7 @@ pub(super) fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { ExportTask::Handouts => { ui.label(RichText::new("Create Handouts").font(theme::font(16.0)).color(t.text)); bullets(ui, &["Several slides on each page of a PDF", "Three a page leaves lines for notes", "Print them for your audience"]); - let combo = egui::ComboBox::from_id_salt("backstage_handouts") + let combo = craft_ui::ComboBox::from_id_salt("backstage_handouts") .width(240.0) .selected_text(format!("{} slides per page", bs.handouts_per_page)) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 97b17388..17ff3d44 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -83,6 +83,69 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { (r, scale) } +/// The mouse wheel or a trackpad flick turns slides when the canvas has nothing to scroll. +fn wheel_navigation(app: &mut SlideApp, ui: &mut Ui, carry_id: egui::Id, doc_uid: u64) { + if app.anim_preview_active() || app.session.tool.marquee.is_some() || app.session.tool.dragging() { + return; + } + let (_, mut carry): (u64, f32) = ui.data_mut(|d| d.get_temp(carry_id).unwrap_or_default()); + let wheel_step = app.session.active().and_then(|d| { + let current = d.selection.slide; + ui.input(|i| wheel_slide_target(&i.events, current, d.doc.slides.len(), &mut carry)).map(|target| (current, target)) + }); + ui.data_mut(|d| d.insert_temp(carry_id, (doc_uid, carry))); + if let Some((current, index)) = wheel_step { + if index != current { + let _ = app.run("slide.go", json!({"index": index})); + } + } else if let Some(next) = page_flick(ui.input(|i| trackpad_scroll(&i.events))) { + // Point-based trackpad gestures keep their cooldown. Raw input avoids reading + // the smoothed tail of a discrete mouse notch as another page turn. + let gate = ui.scope_id().with("wheel_gate"); + let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); + let now = ui.input(|i| i.time); + if now - last > 0.35 { + ui.data_mut(|d| d.insert_temp(gate, now)); + let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({})); + } + } +} + +fn empty_deck_prompt(app: &mut SlideApp, ui: &mut Ui, avail: Rect, t: &Tokens) { + let resp = ui.allocate_rect(avail, Sense::click()); + ui.painter().text(avail.center(), Align2::CENTER_CENTER, "Click to add first slide", theme::font(22.0), t.text_dim); + if resp.clicked() { + let _ = app.run("slide.new", json!({"layout": "title"})); + } +} + +fn reset_wheel_carry(ui: &Ui, carry_id: egui::Id, doc_uid: u64) { + ui.data_mut(|d| { + if d.get_temp::<(u64, f32)>(carry_id).is_none_or(|(uid, _)| uid != doc_uid) { + d.insert_temp(carry_id, (doc_uid, 0.0_f32)); + } + }); +} + +/// The scroll offset that keeps the slide point under `pointer` (or the canvas middle) fixed +/// when the scale changed since the last frame. +fn anchored_offset(last: Option<(Rect, f32)>, content_rect: Rect, scale: f32, pointer: Option, avail: Rect) -> Option { + let (last_rect, last_scale) = last?; + if (scale - last_scale).abs() <= 1e-6 { + return None; + } + let anchor = pointer.unwrap_or(avail.center()); + let at = (anchor - last_rect.min) / last_scale; + Some(content_rect.min + at * scale - anchor) +} + +fn slide_gifs(app: &mut SlideApp, now: f64) -> Vec { + match app.session.active() { + Some(d) if d.selection.target == Target::Slides => app.media.gifs(d.uid, &d.doc, d.selection.slide, now, &app.session.gif_paused), + _ => vec![], + } +} + /// Paint the editing canvas, including rulers, selection handles, overlays, and current /// zoom/scroll state. pub fn show(app: &mut SlideApp, ui: &mut Ui) { @@ -94,17 +157,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { // one is drawn here, hovered or not, so switching away and back starts from zero. let carry_id = ui.scope_id().with("wheel_carry"); let doc_uid = st.uid; - ui.data_mut(|d| { - if d.get_temp::<(u64, f32)>(carry_id).is_none_or(|(uid, _)| uid != doc_uid) { - d.insert_temp(carry_id, (doc_uid, 0.0_f32)); - } - }); + reset_wheel_carry(ui, carry_id, doc_uid); if st.doc.slides.is_empty() && st.selection.target == Target::Slides { - let resp = ui.allocate_rect(avail, Sense::click()); - ui.painter().text(avail.center(), Align2::CENTER_CENTER, "Click to add first slide", theme::font(22.0), t.text_dim); - if resp.clicked() { - let _ = app.run("slide.new", json!({"layout": "title"})); - } + empty_deck_prompt(app, ui, avail, &t); return; } // Scroll when zoomed beyond the window. @@ -122,12 +177,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let (content_rect, scale) = layout(app, avail); // A new zoom keeps the slide point under the pointer (wheel, pinch) or under the middle of the // canvas (Zoom In/Out, the slider) where it was, instead of growing from the corner (#106). - if let Some((last_rect, last_scale)) = last - && (scale - last_scale).abs() > 1e-6 - { - let anchor = if zooming { hover.unwrap_or(avail.center()) } else { avail.center() }; - let at = (anchor - last_rect.min) / last_scale; - offset = content_rect.min + at * scale - anchor; + if let Some(o) = anchored_offset(last, content_rect, scale, zooming.then_some(hover).flatten(), avail) { + offset = o; } let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0)); // Scroll bars on the axes that overflow (#1157). They sit over the canvas edge and take their @@ -175,28 +226,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { if (resp.hovered() || on_bar) && !zooming { if overflow != egui::Vec2::ZERO { offset -= wheel; - } else if !app.anim_preview_active() && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { - let (_, mut carry): (u64, f32) = ui.data_mut(|d| d.get_temp(carry_id).unwrap_or_default()); - let wheel_step = app.session.active().and_then(|d| { - let current = d.selection.slide; - ui.input(|i| wheel_slide_target(&i.events, current, d.doc.slides.len(), &mut carry)).map(|target| (current, target)) - }); - ui.data_mut(|d| d.insert_temp(carry_id, (doc_uid, carry))); - if let Some((current, index)) = wheel_step { - if index != current { - let _ = app.run("slide.go", json!({"index": index})); - } - } else if let Some(next) = page_flick(ui.input(|i| trackpad_scroll(&i.events))) { - // Point-based trackpad gestures keep their cooldown. Raw input avoids reading - // the smoothed tail of a discrete mouse notch as another page turn. - let gate = ui.scope_id().with("wheel_gate"); - let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); - let now = ui.input(|i| i.time); - if now - last > 0.35 { - ui.data_mut(|d| d.insert_temp(gate, now)); - let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({})); - } - } + } else { + wheel_navigation(app, ui, carry_id, doc_uid); } } offset.x = offset.x.clamp(0.0, overflow.x); @@ -218,10 +249,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow); let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32); let now = ui.input(|i| i.time); - let gifs = match app.session.active() { - Some(d) if d.selection.target == Target::Slides => app.media.gifs(d.uid, &d.doc, d.selection.slide, now, &app.session.gif_paused), - _ => vec![], - }; + let gifs = slide_gifs(app, now); crate::media::gif_repaint(ui.ctx(), &gifs); let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); let anim = app.anim_preview_draw(); diff --git a/crates/ui-egui/src/canvas/context_menu.rs b/crates/ui-egui/src/canvas/context_menu.rs index 260838eb..1aa1c928 100644 --- a/crates/ui-egui/src/canvas/context_menu.rs +++ b/crates/ui-egui/src/canvas/context_menu.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Hit, SlideApp, json}; pub(super) fn context_menu(app: &mut SlideApp, resp: &egui::Response) { // Right-clicking a vertex in Edit Points picks it for the point menu. @@ -10,138 +10,12 @@ pub(super) fn context_menu(app: &mut SlideApp, resp: &egui::Response) { resp.context_menu(|ui| { let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned())); if let Some(sh) = editing { - if let Some((path, index)) = app.session.tool.edit_segment { - let cur = deckcraft_engine::cmd::points::segment_kind_of(&sh, path, index); - for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] { - if ui.radio(cur == Some(k), l).clicked() { - let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k})); - ui.close(); - } - } - // Each of these renumbers the points, so the picked segment is dropped. - if ui.button("Add Point").clicked() { - let t = app.session.tool.edit_segment_t.unwrap_or(0.5); - let _ = app.run("shape.addPoint", json!({"path": path, "segment": index, "t": t})); - app.session.tool.edit_segment = None; - ui.close(); - } - let closed = deckcraft_engine::cmd::points::closed_at(&sh, path, index); - if ui.add_enabled(closed == Some(true), egui::Button::new("Delete Segment")).clicked() { - let _ = app.run("shape.deleteSegment", json!({"path": path, "segment": index})); - app.session.tool.edit_segment = None; - ui.close(); - } - // Open Path opens the outline at the segment's start point. - let (label, id, at) = match closed { - Some(false) => ("Close Path", "shape.closePath", index), - _ => ("Open Path", "shape.openPath", deckcraft_engine::cmd::points::segment_start_of(&sh, path, index).unwrap_or(index)), - }; - if ui.button(label).clicked() { - let _ = app.run(id, json!({"path": path, "index": at})); - app.session.tool.edit_segment = None; - ui.close(); - } - ui.separator(); - } else if let Some((path, index)) = app.session.tool.edit_vertex { - let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index); - for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { - if ui.radio(cur == Some(k), l).clicked() { - let _ = app.run("shape.pointType", json!({"path": path, "index": index, "type": k})); - ui.close(); - } - } - if ui.button("Delete Point").clicked() { - let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); - app.session.tool.edit_vertex = None; - ui.close(); - } - let (label, id) = match deckcraft_engine::cmd::points::closed_at(&sh, path, index) { - Some(false) => ("Close Path", "shape.closePath"), - _ => ("Open Path", "shape.openPath"), - }; - if ui.button(label).clicked() { - let _ = app.run(id, json!({"path": path, "index": index})); - // The points are numbered from the new start now. - app.session.tool.edit_vertex = None; - ui.close(); - } - ui.separator(); - } - if ui.button("Exit Edit Points").clicked() { - let _ = app.run("shape.editPoints", json!({"on": false})); - ui.close(); - } + edit_points_menu(app, ui, &sh); return; } // A misspelt word under the pointer: its suggestions, then Ignore All and Add to Dictionary. if let Some(w) = app.squiggles.menu_word.clone() { - if let Some(from) = w.delete_from { - if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { - let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); - // The gap is spaces; take it from the text so tabs or no-break spaces match. - let gap = app - .session - .active() - .and_then(|st| match w.tag { - crate::squiggles::Tag::Shape { id, para } => st.shape(id)?.text.as_ref()?.paragraphs.get(para).map(|p| p.text()), - crate::squiggles::Tag::Cell { id, row, col, para } => match &st.shape(id)?.kind { - deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).map(|p| p.text()), - _ => None, - }, - crate::squiggles::Tag::Notes { .. } => None, - }) - .map(|t| t.chars().skip(from).take(w.start + w.word.chars().count() - from).collect::()) - .unwrap_or(cut); - let mut p = json!({"para": 0, "start": from, "word": gap, "to": ""}); - if let Some(o) = p.as_object_mut() { - match w.tag { - crate::squiggles::Tag::Shape { id, para } => { - o.insert("shape".into(), json!(id)); - o.insert("para".into(), json!(para)); - } - crate::squiggles::Tag::Cell { id, row, col, para } => { - o.insert("shape".into(), json!(id)); - o.insert("row".into(), json!(row)); - o.insert("col".into(), json!(col)); - o.insert("para".into(), json!(para)); - } - crate::squiggles::Tag::Notes { .. } => {} - } - } - let _ = app.run("review.spellingChange", p); - app.squiggles.menu_word = None; - ui.close(); - } - } else if w.suggestions.is_empty() { - ui.label(egui::RichText::new("(no spelling suggestions)").weak()); - } - for to in &w.suggestions { - if ui.button(egui::RichText::new(to).strong()).clicked() { - let p = match w.tag { - crate::squiggles::Tag::Shape { id, para } => json!({"shape": id, "para": para, "start": w.start, "word": w.word, "to": to}), - crate::squiggles::Tag::Cell { id, row, col, para } => { - json!({"shape": id, "row": row, "col": col, "para": para, "start": w.start, "word": w.word, "to": to}) - } - crate::squiggles::Tag::Notes { .. } => json!({}), - }; - let _ = app.run("review.spellingChange", p); - app.squiggles.menu_word = None; - ui.close(); - } - } - if ui.button("Ignore All").clicked() { - app.squiggles.ignore(&w.word); - app.squiggles.menu_word = None; - ui.close(); - } - if ui.button("Add to Dictionary").clicked() { - if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { - app.set_status(e.to_string()); - } - app.squiggles.menu_word = None; - ui.close(); - } - ui.separator(); + spelling_menu(app, ui, &w); } let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] { @@ -154,129 +28,273 @@ pub(super) fn context_menu(app: &mut SlideApp, resp: &egui::Response) { // Right-clicking the text being edited keeps it (`PointerKind::ContextClick`): text items. let editing = app.session.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.notes)); if editing { - if ui.button("Paragraphโ€ฆ").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); - ui.close(); - } - for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] { - if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { - let _ = app.run(id, json!({})); - ui.close(); - } - } - if ui.button("Linkโ€ฆ").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); - ui.close(); - } - ui.separator(); - if ui.button("Format Shapeโ€ฆ").clicked() { - let _ = app.run("view.pane", json!({"pane": "format"})); - ui.close(); - } + text_items(app, ui); } else if has_sel { - if ui.button("Edit Text").clicked() { - let _ = app.run("text.edit", json!({})); + shape_items(app, ui); + } else { + slide_items(app, ui); + } + }); +} + +fn edit_points_menu(app: &mut SlideApp, ui: &mut egui::Ui, sh: &deckcraft_model::Shape) { + if let Some((path, index)) = app.session.tool.edit_segment { + let cur = deckcraft_engine::cmd::points::segment_kind_of(sh, path, index); + for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k})); ui.close(); } - if crate::ribbon::enabled(app, "shape.editPoints") - && app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line())) - && ui.button("Edit Points").clicked() - { - let _ = app.run("shape.editPoints", json!({"on": true})); + } + // Each of these renumbers the points, so the picked segment is dropped. + if ui.button("Add Point").clicked() { + let t = app.session.tool.edit_segment_t.unwrap_or(0.5); + let _ = app.run("shape.addPoint", json!({"path": path, "segment": index, "t": t})); + app.session.tool.edit_segment = None; + ui.close(); + } + let closed = deckcraft_engine::cmd::points::closed_at(sh, path, index); + if ui.add_enabled(closed == Some(true), egui::Button::new("Delete Segment")).clicked() { + let _ = app.run("shape.deleteSegment", json!({"path": path, "segment": index})); + app.session.tool.edit_segment = None; + ui.close(); + } + // Open Path opens the outline at the segment's start point. + let (label, id, at) = match closed { + Some(false) => ("Close Path", "shape.closePath", index), + _ => ("Open Path", "shape.openPath", deckcraft_engine::cmd::points::segment_start_of(sh, path, index).unwrap_or(index)), + }; + if ui.button(label).clicked() { + let _ = app.run(id, json!({"path": path, "index": at})); + app.session.tool.edit_segment = None; + ui.close(); + } + ui.separator(); + } else if let Some((path, index)) = app.session.tool.edit_vertex { + let cur = deckcraft_engine::cmd::points::kind_of(sh, path, index); + for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.pointType", json!({"path": path, "index": index, "type": k})); ui.close(); } - if ui.button("Fontโ€ฆ").clicked() { - let _ = app.run("format.fontDialog", json!({})); - ui.close(); - } - if ui.button("Paragraphโ€ฆ").clicked() { - let _ = app.run("format.paragraphDialog", json!({})); - ui.close(); - } - ui.menu_button("Bring to Front", |ui| { - crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); - crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); - }); - ui.menu_button("Send to Back", |ui| { - crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); - crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); - }); - ui.menu_button("Group", |ui| { - crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); - crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); - }); - let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked)); - if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() { - let _ = app.run("shape.lock", json!({"locked": !locked})); - ui.close(); - } - let picture = app + } + if ui.button("Delete Point").clicked() { + let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); + app.session.tool.edit_vertex = None; + ui.close(); + } + let (label, id) = match deckcraft_engine::cmd::points::closed_at(sh, path, index) { + Some(false) => ("Close Path", "shape.closePath"), + _ => ("Open Path", "shape.openPath"), + }; + if ui.button(label).clicked() { + let _ = app.run(id, json!({"path": path, "index": index})); + // The points are numbered from the new start now. + app.session.tool.edit_vertex = None; + ui.close(); + } + ui.separator(); + } + if ui.button("Exit Edit Points").clicked() { + let _ = app.run("shape.editPoints", json!({"on": false})); + ui.close(); + } +} + +fn spelling_menu(app: &mut SlideApp, ui: &mut egui::Ui, w: &crate::squiggles::MenuWord) { + if let Some(from) = w.delete_from { + if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); + // The gap is spaces; take it from the text so tabs or no-break spaces match. + let gap = app .session .active() - .is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. }))); - if picture && ui.button("Change Pictureโ€ฆ").clicked() { - let _ = app.run("app.changePictureDialog", json!({})); - ui.close(); - } - if ui.button("Linkโ€ฆ").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); - ui.close(); - } - if ui.button("Edit Alt Textโ€ฆ").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("altText")); - ui.close(); - } - if ui.button("Set as Default Shape").clicked() { - let _ = app.run("shape.setDefault", json!({})); - ui.close(); - } - if ui.button("Size and Positionโ€ฆ").clicked() { - let _ = app.run("shape.sizePosition", json!({})); - ui.close(); - } - if ui.button("Format Shapeโ€ฆ").clicked() { - let _ = app.run("view.pane", json!({"pane": "format"})); - ui.close(); - } - } else { - ui.menu_button("Layout", |ui| { - let layouts: Vec<(String, u32)> = app - .session - .active() - .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect())) - .unwrap_or_default(); - for (n, id) in layouts { - if ui.button(n).clicked() { - let _ = app.run("slide.layout", json!({"layout": id})); - ui.close(); + .and_then(|st| match w.tag { + crate::squiggles::Tag::Shape { id, para } => st.shape(id)?.text.as_ref()?.paragraphs.get(para).map(|p| p.text()), + crate::squiggles::Tag::Cell { id, row, col, para } => match &st.shape(id)?.kind { + deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).map(|p| p.text()), + _ => None, + }, + crate::squiggles::Tag::Notes { .. } => None, + }) + .map(|t| t.chars().skip(from).take(w.start + w.word.chars().count() - from).collect::()) + .unwrap_or(cut); + let mut p = json!({"para": 0, "start": from, "word": gap, "to": ""}); + if let Some(o) = p.as_object_mut() { + match w.tag { + crate::squiggles::Tag::Shape { id, para } => { + o.insert("shape".into(), json!(id)); + o.insert("para".into(), json!(para)); } - } - }); - ui.menu_button("Grid and Guides", |ui| { - let mut on = app.ui.guides; - if ui.checkbox(&mut on, "Guides").clicked() { - let _ = app.run("view.guides", json!({"on": on})); - } - for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { - if ui.button(label).clicked() { - let _ = app.run("guide.add", json!({"vertical": vertical})); - let _ = app.run("view.guides", json!({"on": true})); - ui.close(); + crate::squiggles::Tag::Cell { id, row, col, para } => { + o.insert("shape".into(), json!(id)); + o.insert("row".into(), json!(row)); + o.insert("col".into(), json!(col)); + o.insert("para".into(), json!(para)); } + crate::squiggles::Tag::Notes { .. } => {} } - }); - if ui.button("Reset Slide").clicked() { - let _ = app.run("slide.reset", json!({})); - ui.close(); } - if ui.button("Format Backgroundโ€ฆ").clicked() { - let _ = app.run("view.pane", json!({"pane": "background"})); - ui.close(); - } - if ui.button("New Comment").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("comment")); + let _ = app.run("review.spellingChange", p); + app.squiggles.menu_word = None; + ui.close(); + } + } else if w.suggestions.is_empty() { + ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + } + for to in &w.suggestions { + if ui.button(egui::RichText::new(to).strong()).clicked() { + let p = match w.tag { + crate::squiggles::Tag::Shape { id, para } => json!({"shape": id, "para": para, "start": w.start, "word": w.word, "to": to}), + crate::squiggles::Tag::Cell { id, row, col, para } => { + json!({"shape": id, "row": row, "col": col, "para": para, "start": w.start, "word": w.word, "to": to}) + } + crate::squiggles::Tag::Notes { .. } => json!({}), + }; + let _ = app.run("review.spellingChange", p); + app.squiggles.menu_word = None; + ui.close(); + } + } + if ui.button("Ignore All").clicked() { + app.squiggles.ignore(&w.word); + app.squiggles.menu_word = None; + ui.close(); + } + if ui.button("Add to Dictionary").clicked() { + if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { + app.set_status(e.to_string()); + } + app.squiggles.menu_word = None; + ui.close(); + } + ui.separator(); +} + +fn text_items(app: &mut SlideApp, ui: &mut egui::Ui) { + if ui.button("Paragraphโ€ฆ").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); + ui.close(); + } + for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] { + if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + if ui.button("Linkโ€ฆ").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); + ui.close(); + } + ui.separator(); + if ui.button("Format Shapeโ€ฆ").clicked() { + let _ = app.run("view.pane", json!({"pane": "format"})); + ui.close(); + } +} + +fn shape_items(app: &mut SlideApp, ui: &mut egui::Ui) { + if ui.button("Edit Text").clicked() { + let _ = app.run("text.edit", json!({})); + ui.close(); + } + if crate::ribbon::enabled(app, "shape.editPoints") + && app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line())) + && ui.button("Edit Points").clicked() + { + let _ = app.run("shape.editPoints", json!({"on": true})); + ui.close(); + } + if ui.button("Fontโ€ฆ").clicked() { + let _ = app.run("format.fontDialog", json!({})); + ui.close(); + } + if ui.button("Paragraphโ€ฆ").clicked() { + let _ = app.run("format.paragraphDialog", json!({})); + ui.close(); + } + ui.menu_button("Bring to Front", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); + }); + ui.menu_button("Send to Back", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); + }); + ui.menu_button("Group", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); + }); + let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked)); + if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() { + let _ = app.run("shape.lock", json!({"locked": !locked})); + ui.close(); + } + let picture = + app.session.active().is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. }))); + if picture && ui.button("Change Pictureโ€ฆ").clicked() { + let _ = app.run("app.changePictureDialog", json!({})); + ui.close(); + } + if ui.button("Linkโ€ฆ").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); + ui.close(); + } + if ui.button("Edit Alt Textโ€ฆ").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("altText")); + ui.close(); + } + if ui.button("Set as Default Shape").clicked() { + let _ = app.run("shape.setDefault", json!({})); + ui.close(); + } + if ui.button("Size and Positionโ€ฆ").clicked() { + let _ = app.run("shape.sizePosition", json!({})); + ui.close(); + } + if ui.button("Format Shapeโ€ฆ").clicked() { + let _ = app.run("view.pane", json!({"pane": "format"})); + ui.close(); + } +} + +fn slide_items(app: &mut SlideApp, ui: &mut egui::Ui) { + ui.menu_button("Layout", |ui| { + let layouts: Vec<(String, u32)> = app + .session + .active() + .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect())) + .unwrap_or_default(); + for (n, id) in layouts { + if ui.button(n).clicked() { + let _ = app.run("slide.layout", json!({"layout": id})); ui.close(); } } }); + ui.menu_button("Grid and Guides", |ui| { + let mut on = app.ui.guides; + if ui.checkbox(&mut on, "Guides").clicked() { + let _ = app.run("view.guides", json!({"on": on})); + } + for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { + if ui.button(label).clicked() { + let _ = app.run("guide.add", json!({"vertical": vertical})); + let _ = app.run("view.guides", json!({"on": true})); + ui.close(); + } + } + }); + if ui.button("Reset Slide").clicked() { + let _ = app.run("slide.reset", json!({})); + ui.close(); + } + if ui.button("Format Backgroundโ€ฆ").clicked() { + let _ = app.run("view.pane", json!({"pane": "background"})); + ui.close(); + } + if ui.button("New Comment").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("comment")); + ui.close(); + } } diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs index be23d2ee..50899615 100644 --- a/crates/ui-egui/src/canvas/guides.rs +++ b/crates/ui-egui/src/canvas/guides.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, CornerRadius, Point, Rect, SlideApp, Stroke, Tokens, Xf, pos2, theme}; /// The presentation's drawing guides; the one being dragged at its new place, labelled with its /// distance from the slide's centre in points. diff --git a/crates/ui-egui/src/canvas/ime.rs b/crates/ui-egui/src/canvas/ime.rs index ef752efa..61f47bc1 100644 --- a/crates/ui-egui/src/canvas/ime.rs +++ b/crates/ui-egui/src/canvas/ime.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Rect, SlideApp, Ui, Xf, screen_rect, vec2}; /// Where an input method composes for the slide (#101), on screen: at the insertion point while /// editing text, or over the one selected shape (typing on it starts editing). `None` when there is diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 4cfd6218..c5e7e8ee 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Hit, Point, PointerEvent, PointerKind, SlideApp, ToolKind, Ui, Xf, json, mods}; /// A two-finger twist on the trackpad. macOS delivers it as [`egui::Event::Rotate`]; /// other platforms leave the delta at zero. diff --git a/crates/ui-egui/src/canvas/media.rs b/crates/ui-egui/src/canvas/media.rs index a859beb8..5beb44b1 100644 --- a/crates/ui-egui/src/canvas/media.rs +++ b/crates/ui-egui/src/canvas/media.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Rect, Sense, SlideApp, Target, Ui, Xf, json, pos2, screen_rect, vec2}; /// Play/pause on an animated GIF under the pointer or selected on its own. pub(super) fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) { diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 94c731b8..ff59a8e3 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -1,4 +1,5 @@ -use super::*; +use super::{Color32, CornerRadius, Point, Pos2, Rect, SlideApp, Stroke, Tokens, ToolKind, Ui, Xf, Xfrm, box_handles, pos2, theme}; +use deckcraft_engine::DocState; fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) { for i in 0..4 { @@ -6,16 +7,7 @@ fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) { } } -pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { - // Spelling squiggles under misspelt words (checked in the background). - if let Some(st) = app.session.active() { - let lines = app.squiggles.update(st, Some(ui.ctx())); - let scale = (xf.to_screen(Point::new(1.0, 0.0)) - xf.to_screen(Point::new(0.0, 0.0))).length(); - for (a, b) in lines { - crate::squiggles::paint(painter, xf.to_screen(a), xf.to_screen(b), scale); - } - } - let Some(st) = app.session.active() else { return }; +fn selection_handles(app: &SlideApp, st: &DocState, painter: &egui::Painter, xf: Xf, t: &Tokens) { let sel_stroke = Stroke::new(1.0, t.selection); let editing = st.selection.text.clone(); for id in &st.selection.shapes { @@ -104,6 +96,10 @@ pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: } } } +} + +fn text_overlays(st: &DocState, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { + let editing = st.selection.text.clone(); // Caret / text selection, in a shape or a table cell. if let Some(ts) = &editing { for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) { @@ -148,6 +144,20 @@ pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: painter.add(egui::Shape::convex_polygon(pts.iter().map(|q| xf.to_screen(f.affine * *q)).collect(), t.text_selection, Stroke::NONE)); } } +} + +pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { + // Spelling squiggles under misspelt words (checked in the background). + if let Some(st) = app.session.active() { + let lines = app.squiggles.update(st, Some(ui.ctx())); + let scale = (xf.to_screen(Point::new(1.0, 0.0)) - xf.to_screen(Point::new(0.0, 0.0))).length(); + for (a, b) in lines { + crate::squiggles::paint(painter, xf.to_screen(a), xf.to_screen(b), scale); + } + } + let Some(st) = app.session.active() else { return }; + selection_handles(app, st, painter, xf, t); + text_overlays(st, ui, painter, xf, t); // Smart guides, marquee, previews. for g in &app.session.tool.guides { let (a, b) = diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs index bb8cb71a..db714991 100644 --- a/crates/ui-egui/src/canvas/rulers.rs +++ b/crates/ui-egui/src/canvas/rulers.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, CornerRadius, Point, Pos2, Rect, Sense, SlideApp, Stroke, Tokens, Ui, Xf, json, pos2, theme, vec2}; /// The ruler's numbered step in points and the ticks per step: quarter inches, or half /// centimetres. Any other unit (only a hand-edited `prefs.json` gives one) keeps inches, since a diff --git a/crates/ui-egui/src/canvas/scroll.rs b/crates/ui-egui/src/canvas/scroll.rs index ff3b072c..19ba9c6d 100644 --- a/crates/ui-egui/src/canvas/scroll.rs +++ b/crates/ui-egui/src/canvas/scroll.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Rect, pos2}; /// Thickness of the canvas scroll bars, in points. const SCROLL_BAR: f32 = 10.0; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 00ac33c7..ed33b9cd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -262,7 +262,7 @@ fn color_label(v: &Value) -> String { fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, name: &str, key: &str, choices: &[(&str, &str)]) { let cur = d.get(key, ""); let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str()); - let combo = egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| { for (v, l) in choices { if ui.selectable_label(cur == *v, *l).clicked() { d.fields.insert((*key).into(), (*v).into()); diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs index a5f80aec..03c6629b 100644 --- a/crates/ui-egui/src/dialogs/about.rs +++ b/crates/ui-egui/src/dialogs/about.rs @@ -1,4 +1,4 @@ -use super::*; +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); @@ -70,7 +70,8 @@ mod about_credits_tests { use egui_kittest::Harness; use egui_kittest::kittest::Queryable; - use super::{CREDITS_SIZE, Dialog, credits_block}; + use super::{CREDITS_SIZE, credits_block}; + use crate::dialogs::Dialog; use crate::{Services, SlideApp}; fn tab_of(h: &Harness<'_, SlideApp>) -> String { diff --git a/crates/ui-egui/src/dialogs/body.rs b/crates/ui-egui/src/dialogs/body.rs index f6efcac4..cb1a879e 100644 --- a/crates/ui-egui/src/dialogs/body.rs +++ b/crates/ui-egui/src/dialogs/body.rs @@ -19,1321 +19,48 @@ use super::theme_colors::theme_colors_dialog; use super::theme_fonts::theme_fonts_dialog; use super::trim::{seconds_text_to_ms, trim_seed}; use super::zoom::zoom_dialog; -use super::*; +use super::{Dialog, SlideApp, Tokens, Ui, Value, buttons, check, field, field_of, json, num, theme, vec2}; /// Returns true when the dialog should close. pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - let run = |app: &mut SlideApp, id: &str, p: Value| { - let _ = app.run(id, p); - }; match d.id.as_str() { - "table" => { - field(ui, d, "Number of columns:", "cols", "5"); - field(ui, d, "Number of rows:", "rows", "2"); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - let (r, c) = (num(d, "rows", 2.0) as usize, num(d, "cols", 5.0) as usize); - run(app, "insert.table", json!({"rows": r, "cols": c})); - } - ok || cancel - } - "slideSize" => { - let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); - let len = app.session.prefs.units; - let ppu = len.points_per_unit(); - // Each field keeps its exact size in points (`wPt`) beside the two decimals shown, so - // a preset, Portrait or an untouched field is not nudged off by the rounding (13.33", - // 33.87 cm read back would make the slide 959.76 or 960.09 pt wide). - if !d.fields.contains_key("wPt") { - put_slide_dim(d, "w", cur.width, ppu); - put_slide_dim(d, "h", cur.height, ppu); - } - let label = ui.label("Slides sized for:"); - let preset = d.get("preset", ""); - let combo = egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| { - for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES { - if ui.selectable_label(preset == *label, *label).clicked() { - d.fields.insert("preset".into(), label.to_string()); - put_slide_dim(d, "w", *w, ppu); - put_slide_dim(d, "h", *h, ppu); - } - } - }); - let _ = combo.response.labelled_by(label.id); - let abbr = unit_abbr(len); - let (w_seed, h_seed) = (field_of(d, "w"), field_of(d, "h")); - field(ui, d, &format!("Width ({abbr}):"), "w", &w_seed); - field(ui, d, &format!("Height ({abbr}):"), "h", &h_seed); - let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); - ui.horizontal(|ui| { - ui.label("Orientation:"); - let portrait = ui.radio(h > w, "Portrait").clicked(); - let landscape = ui.radio(w >= h, "Landscape").clicked(); - // Picking the other orientation swaps the width and height fields. - if portrait && w > h || landscape && h > w { - put_slide_dim(d, "w", h, ppu); - put_slide_dim(d, "h", w, ppu); - } - }); - let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); - run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }})); - } - ok || cancel - } + "table" => dlg_table(app, ui, d), + "slideSize" => dlg_slide_size(app, ui, d), "sizePosition" => size_position_dialog(app, ui, d), - "headerFooter" => { - let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default(); - let date = check(ui, d, "Date and time", "date", hf.date); - field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text); - // The format applies to an automatic date; a fixed one is the text as typed. - let automatic = d.get("dateText", &hf.date_text).is_empty(); - let fmt_now = if hf.date_format.is_empty() { "datetime1".to_string() } else { hf.date_format.clone() }; - // Not `d.get`: that stores the default as if the user had picked it. - let fmt = d.fields.get("dateFormat").cloned().unwrap_or_else(|| fmt_now.clone()); - let rows = date_rows("en-US"); - let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone()); - ui.add_enabled_ui(date && automatic, |ui| { - ui.horizontal(|ui| { - ui.label(" Format:"); - egui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| { - for (kind, sample) in &rows { - if ui.selectable_label(fmt == *kind, sample).clicked() { - d.fields.insert("dateFormat".into(), (*kind).to_string()); - } - } - }); - }); - }); - let num_on = check(ui, d, "Slide number", "num", hf.slide_number); - let foot = check(ui, d, "Footer", "footer", hf.footer); - field(ui, d, " Footer text:", "footerText", &hf.footer_text); - let hide = check(ui, d, "Don't show on title slide", "hide", hf.hide_on_title); - ui.add_space(6.0); - let mut apply_all = false; - let mut apply = false; - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - apply_all = ui.button("Apply to All").clicked(); - apply = ui.button("Apply").clicked(); - if ui.button("Cancel").clicked() { - d.fields.insert("cancel".into(), "1".into()); - } - }); - if apply || apply_all { - let dt = d.get("dateText", ""); - let ft = d.get("footerText", ""); - let mut params = serde_json::Map::new(); - params.insert("date".into(), json!(date)); - params.insert("dateText".into(), json!(dt)); - // Only a format the user picked: sending the default would rewrite every slide's - // existing date field (an imported datetime4, say) to datetime1. - if d.fields.contains_key("dateFormat") { - params.insert("dateFormat".into(), json!(d.get("dateFormat", &fmt_now))); - } - params.insert("slideNumber".into(), json!(num_on)); - params.insert("footer".into(), json!(foot)); - params.insert("footerText".into(), json!(ft)); - params.insert("hideOnTitle".into(), json!(hide)); - params.insert("all".into(), json!(apply_all)); - run(app, "design.headerFooter", serde_json::Value::Object(params)); - } - apply || apply_all || d.fields.contains_key("cancel") - } - "hyperlink" => { - let seed = hyperlink_seed_of(app.session.active()); - let slide_default = seed.slide.to_string(); - let mode = d.get("mode", seed.mode); - ui.horizontal(|ui| { - if ui.selectable_label(mode == "url", "Web Page or File").clicked() { - d.fields.insert("mode".into(), "url".into()); - } - if ui.selectable_label(mode == "slide", "This Document").clicked() { - d.fields.insert("mode".into(), "slide".into()); - } - if ui.selectable_label(mode == "email", "Email Address").clicked() { - d.fields.insert("mode".into(), "email".into()); - } - }); - match mode.as_str() { - "slide" => { - let titles: Vec = app - .session - .active() - .map(|s| s.doc.slides.iter().enumerate().map(|(i, sl)| format!("{}. {}", i + 1, sl.title())).collect()) - .unwrap_or_default(); - let sel = d.get("slide", &slide_default).parse::().unwrap_or(0); - egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - for (i, t) in titles.iter().enumerate() { - if ui.selectable_label(sel == i, t).clicked() { - d.fields.insert("slide".into(), i.to_string()); - } - } - }); - } - "email" => field(ui, d, "Email address:", "email", &seed.email), - _ => field(ui, d, "Address:", "url", &seed.url), - } - field(ui, d, "ScreenTip:", "tip", &seed.tip); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let mode_now = d.get("mode", seed.mode); - let url = d.get("url", &seed.url); - let email = d.get("email", &seed.email); - let slide = d.get("slide", &slide_default).parse::().unwrap_or(0); - let tip = d.get("tip", &seed.tip); - if hyperlink_should_apply(&seed, &mode_now, &url, &email, slide) { - run(app, "insert.hyperlink", hyperlink_command(&mode_now, &url, &email, slide, &tip, seed.highlight)); - } - } - ok || cancel - } - "setupShow" => { - let sh = app.session.active().map(|s| s.doc.show.clone()).unwrap_or_default(); - let ty = d.get("type", &sh.show_type); - ui.label("Show type"); - for (l, v) in [ - ("Presented by a speaker (full screen)", "speaker"), - ("Browsed by an individual (window)", "browsed"), - ("Browsed at a kiosk (full screen)", "kiosk"), - ] { - if ui.radio(ty == v, l).clicked() { - d.fields.insert("type".into(), v.into()); - } - } - let lp = check(ui, d, "Loop continuously until 'Esc'", "loop", sh.loop_until_esc); - let nn = check(ui, d, "Show without narration", "nn", sh.without_narration); - let na = check(ui, d, "Show without animation", "na", sh.without_animation); - let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); - let kc = check(ui, d, "Keep mouse cursor visible", "kc", app.ui.show_keep_cursor); - let names: Vec = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); - let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone(); - let field = ui.label("Slides"); - let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; - let combo = egui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { - if ui.selectable_label(chosen.is_empty(), "All slides").clicked() { - d.fields.insert("custom".into(), String::new()); - } - for n in &names { - if ui.selectable_label(&chosen == n, n).clicked() { - d.fields.insert("custom".into(), n.clone()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let ty = d.get("type", "speaker"); - let custom = d.fields.get("custom").cloned().unwrap_or_default(); - run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut, "custom": custom})); - run(app, "show.keepCursor", json!({"on": kc})); - } - ok || cancel - } - "quit" => { - let names: Vec = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| d.title()).collect(); - if names.is_empty() { - app.quit_confirmed = true; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - return true; - } - let what = if names.len() == 1 { format!("โ€œ{}โ€", names[0]) } else { format!("{} presentations", names.len()) }; - ui.label(egui::RichText::new(format!("Do you want to save the changes you made to {what}?")).strong()); - ui.label("Your changes will be lost if you don't save them."); - ui.add_space(8.0); - let mut done = false; - ui.horizontal(|ui| { - let t = Tokens::get(ui.ctx()); - if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { - let _ = app.session.execute("file.recovery.discard", &json!({})); - app.quit_confirmed = true; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - done = true; - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - 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)) { - if app.save_all() { - app.quit_confirmed = true; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - } - done = true; - } - if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { - done = true; - } - }); - }); - done - } - "close" => { - // File โ€บ Close and Ctrl+W on a presentation with unsaved changes. - let Some(name) = app.session.active().map(|d| d.title()) else { return true }; - ui.label(egui::RichText::new(format!("Do you want to save the changes you made to โ€œ{name}โ€?")).strong()); - ui.label("Your changes will be lost if you don't save them."); - ui.add_space(8.0); - let mut done = false; - ui.horizontal(|ui| { - let t = Tokens::get(ui.ctx()); - if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { - run(app, "file.close", json!({})); - done = true; - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - 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(); - if web || app.session.active().is_some_and(|d| !d.is_dirty()) { - run(app, "file.close", json!({})); - } - done = true; - } - if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { - done = true; - } - }); - }); - done - } + "headerFooter" => dlg_header_footer(app, ui, d), + "hyperlink" => dlg_hyperlink(app, ui, d), + "setupShow" => dlg_setup_show(app, ui, d), + "quit" => dlg_quit(app, ui, d), + "close" => dlg_close(app, ui, d), "zoom" => zoom_dialog(app, ui, d), - "comment" => { - let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Comment").desired_rows(4).desired_width(320.0)); - d.fields.insert("text".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Post"); - if ok && !v.trim().is_empty() { - run(app, "comment.add", json!({"text": v})); - app.ui.pane = Some("comments".into()); - } - ok || cancel - } - "bullets" => { - // The current paragraph's list, as the dialog's starting point. - let cur = app.session.execute("format.state", &json!({})).ok().unwrap_or(Value::Null); - let numbered = cur.get("numbering").and_then(Value::as_bool).unwrap_or(false); - let tab = d.get("tab", if numbered { "numbered" } else { "bulleted" }); - ui.horizontal(|ui| { - for (k, l) in [("bulleted", "Bulleted"), ("numbered", "Numbered")] { - if ui.selectable_label(tab == k, l).clicked() { - d.fields.insert("tab".into(), k.into()); - } - } - }); - ui.separator(); - let style_key = if tab == "numbered" { "scheme" } else { "char" }; - let current = d.get(style_key, if tab == "numbered" { "arabicPeriod" } else { "โ€ข" }); - let styles: &[(&str, &str)] = if tab == "numbered" { - &[ - ("arabicPeriod", "1. 2. 3."), - ("arabicParenR", "1) 2) 3)"), - ("romanUcPeriod", "I. II. III."), - ("romanLcPeriod", "i. ii. iii."), - ("alphaUcPeriod", "A. B. C."), - ("alphaLcParenR", "a) b) c)"), - ("alphaLcPeriod", "a. b. c."), - ] - } else { - &[("โ€ข", "โ€ข"), ("โ—‹", "โ—‹"), ("โ– ", "โ– "), ("โ–ก", "โ–ก"), ("โ—†", "โ—†"), ("โžข", "โžข"), ("โœ“", "โœ“"), ("โ€“", "โ€“"), ("โ˜…", "โ˜…")] - }; - egui::Grid::new("bullet-styles").show(ui, |ui| { - for (i, (k, l)) in styles.iter().enumerate() { - let r = ui.add_sized( - vec2(if tab == "numbered" { 88.0 } else { 44.0 }, 40.0), - egui::Button::selectable(current == *k, egui::RichText::new(*l).size(16.0)), - ); - if r.clicked() { - d.fields.insert(style_key.into(), (*k).into()); - } - if (i + 1) % if tab == "numbered" { 4 } else { 5 } == 0 { - ui.end_row(); - } - } - }); - if tab == "bulleted" { - field(ui, d, "Custom character:", "custom", ""); - } else { - field(ui, d, "Start at:", "start", "1"); - } - field(ui, d, "Size (% of text):", "size", "100"); - let colors = [ - ("", "Automatic"), - ("tx1", "Text"), - ("accent1", "Accent 1"), - ("accent2", "Accent 2"), - ("accent3", "Accent 3"), - ("accent4", "Accent 4"), - ("accent5", "Accent 5"), - ("accent6", "Accent 6"), - ]; - let color = d.get("color", ""); - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); - let combo = egui::ComboBox::from_id_salt("bullet-color") - .selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1)) - .show_ui(ui, |ui| { - for (k, l) in colors { - if ui.selectable_label(color == k, l).clicked() { - d.fields.insert("color".into(), k.into()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - ui.horizontal(|ui| { - if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() { - d.fields.insert("none".into(), "1".into()); - } - }); - let (ok, cancel) = buttons(ui, "OK"); - let none = d.fields.contains_key("none"); - if none { - run(app, if tab == "numbered" { "format.numbering" } else { "format.bullets" }, json!({"on": false})); - } else if ok { - let mut p = json!({"on": true}); - if let Some(size) = d.get("size", "100").trim().trim_end_matches('%').parse::().ok().filter(|v| v.is_finite()) { - p["size"] = json!(size.clamp(25.0, 400.0)); - } - if !color.is_empty() { - p["color"] = json!(color); - } - if tab == "numbered" { - p["scheme"] = json!(d.get("scheme", "arabicPeriod")); - p["start"] = json!(d.get("start", "1").trim().parse::().unwrap_or(1).clamp(1, 32767)); - run(app, "format.numbering", p); - } else { - let custom = d.get("custom", ""); - let ch = custom.chars().find(|c| !c.is_whitespace()).map(String::from).unwrap_or_else(|| d.get("char", "โ€ข")); - p["char"] = json!(ch); - run(app, "format.bullets", p); - } - } - ok || cancel || none - } - "action" => { - use deckcraft_model::text::Action; - let tab = d.get("tab", "click"); - // Start from the selected shape's current setting for this tab. - let (cur, slides, shows) = app - .session - .active() - .map(|st| { - let sh = st.selected_shapes().first().map(|x| if tab == "hover" { x.hover.clone() } else { x.click.clone() }).unwrap_or(None); - let slides: Vec<(deckcraft_model::SlideId, String)> = - st.doc.slides.iter().enumerate().map(|(i, sl)| (sl.id, format!("{}. {}", i + 1, sl.title()))).collect(); - let shows: Vec = st.doc.custom_shows.iter().map(|c| c.name.clone()).collect(); - (sh, slides, shows) - }) - .unwrap_or_default(); - let (cur_kind, cur_arg) = match cur.as_ref().map(|h| &h.action) { - None => ("none", String::new()), - Some(Action::NextSlide) => ("next", String::new()), - Some(Action::PreviousSlide) => ("previous", String::new()), - Some(Action::FirstSlide) => ("first", String::new()), - Some(Action::LastSlide) => ("last", String::new()), - Some(Action::LastViewed) => ("lastViewed", String::new()), - Some(Action::EndShow) => ("endShow", String::new()), - Some(Action::PlayMedia) => ("playMedia", String::new()), - Some(Action::Url { url }) => ("url", url.clone()), - Some(Action::CustomShow { name, .. }) => ("customShow", name.clone()), - Some(Action::Program { path }) => ("program", path.clone()), - Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()), - }; - ui.horizontal(|ui| { - for (k, l) in [("click", "Mouse Click"), ("hover", "Mouse Over")] { - if ui.selectable_label(tab == k, l).clicked() && tab != k { - d.fields.clear(); - d.fields.insert("tab".into(), k.into()); - } - } - }); - ui.separator(); - ui.label(if tab == "hover" { "Action on mouse over" } else { "Action on click" }); - let kind = d.get("kind", cur_kind); - let hyperlink = !matches!(kind.as_str(), "none" | "program" | "playMedia"); - let pick = |d: &mut Dialog, ui: &mut Ui, k: &str, l: &str| { - if ui.radio(d.get("kind", cur_kind) == k, l).clicked() { - d.fields.insert("kind".into(), k.into()); - } - }; - pick(d, ui, "none", "None"); - ui.horizontal(|ui| { - let field = ui.radio(hyperlink, "Hyperlink to:"); - if field.clicked() && !hyperlink { - d.fields.insert("kind".into(), "next".into()); - } - let targets = [ - ("next", "Next Slide"), - ("previous", "Previous Slide"), - ("first", "First Slide"), - ("last", "Last Slide"), - ("lastViewed", "Last Slide Viewed"), - ("endShow", "End Show"), - ("customShow", "Custom Showโ€ฆ"), - ("slide", "Slideโ€ฆ"), - ("url", "URLโ€ฆ"), - ]; - let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1); - ui.add_enabled_ui(hyperlink, |ui| { - let combo = egui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| { - for (k, l) in targets { - if ui.selectable_label(kind == k, l).clicked() { - d.fields.insert("kind".into(), k.into()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - }); - let kind = d.get("kind", cur_kind); - match kind.as_str() { - "url" => field(ui, d, "Address:", "arg", if cur_kind == "url" { &cur_arg } else { "https://" }), - "slide" => { - let sel = d.get("arg", if cur_kind == "slide" { &cur_arg } else { "0" }).parse::().unwrap_or(0); - egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { - for (i, (_, t)) in slides.iter().enumerate() { - if ui.selectable_label(sel == i, t).clicked() { - d.fields.insert("arg".into(), i.to_string()); - } - } - }); - } - "customShow" => { - if shows.is_empty() { - ui.label("This presentation has no custom shows (Slide Show โ–ธ Custom Slide Show)."); - } - let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) }); - for n in &shows { - if ui.selectable_label(&sel == n, n).clicked() { - d.fields.insert("arg".into(), n.clone()); - } - } - } - _ => {} - } - pick(d, ui, "program", "Run program:"); - if d.get("kind", cur_kind) == "program" { - field(ui, d, "Program:", "arg", if cur_kind == "program" { &cur_arg } else { "" }); - } - pick(d, ui, "playMedia", "Play the shape's media"); - let hl = check(ui, d, "Highlight click", "highlight", cur.as_ref().is_none_or(|h| h.highlight_click)); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let kind = d.get("kind", cur_kind); - let arg = d.get("arg", &cur_arg); - let mut p = json!({"on": tab, "action": kind, "highlight": hl}); - match kind.as_str() { - "url" => p["url"] = json!(arg), - "slide" => p["slide"] = json!(arg.parse::().unwrap_or(0)), - "customShow" => p["show"] = json!(arg), - "program" => p["path"] = json!(arg), - _ => {} - } - run(app, "shape.action", p); - } - ok || cancel - } - "altText" => { - let (cur, deco_now) = app - .session - .active() - .map(|s| { - let shapes = s.selected_shapes(); - let cur = shapes.first().map(|x| x.descr.clone()).unwrap_or_default(); - let deco = alt_text_starts_decorative(shapes.iter().map(|x| x.decorative)); - (cur, deco) - }) - .unwrap_or_default(); - ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); - let mut v = d.get("text", &cur); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Alt text").desired_rows(4).desired_width(340.0)); - d.fields.insert("text".into(), v.clone()); - let deco = check(ui, d, "Mark as decorative", "deco", deco_now); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run(app, "shape.altText", json!({"text": v, "decorative": deco})); - } - ok || cancel - } - "outline" => { - ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets."); - let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Outline").desired_rows(10).desired_width(380.0).code_editor()); - d.fields.insert("text".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - run(app, "slide.fromOutline", json!({"text": v})); - } - ok || cancel - } - "reuseSlides" => { - let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string(); - let slides: Vec<(usize, String)> = d - .params - .get("slides") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|x| { - Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string())) - }) - .collect() - }) - .unwrap_or_default(); - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); - ui.label(format!("Insert slides from {name} after the current slide:")); - // Unticked slides are remembered by index; everything starts ticked. - let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1"); - ui.horizontal(|ui| { - if ui.small_button("Select All").clicked() { - for (i, _) in &slides { - d.fields.remove(&format!("off{i}")); - } - } - if ui.small_button("Select None").clicked() { - for (i, _) in &slides { - d.fields.insert(format!("off{i}"), "1".into()); - } - } - }); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (i, title) in &slides { - let mut on = !off(d, *i); - let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) }; - if ui.checkbox(&mut on, label).changed() { - if on { - d.fields.remove(&format!("off{i}")); - } else { - d.fields.insert(format!("off{i}"), "1".into()); - } - } - } - }); - let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false); - if ui.checkbox(&mut keep, "Keep source formatting").changed() - && let Some(o) = d.params.as_object_mut() - { - o.insert("keep".into(), json!(keep)); - } - let picked: Vec = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect(); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - if picked.is_empty() { - app.set_status("Tick at least one slide to insert".to_string()); - return false; - } - // A failed insert keeps the dialog (and the ticked slides) open; the error is in the status bar. - let source = d.params.get("source").cloned().unwrap_or_default(); - return app.reuse_slides(&source, keep, &picked); - } - cancel - } - "renameSection" | "renameShape" | "renameLayout" => { - let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string(); - field(ui, d, "Name:", "name", &cur); - let (ok, cancel) = buttons(ui, "Rename"); - if ok { - let name = d.get("name", ""); - match d.id.as_str() { - "renameSection" => run(app, "section.rename", json!({"index": d.params.get("index").cloned().unwrap_or_default(), "name": name})), - "renameShape" => run(app, "shape.rename", json!({"id": d.params.get("id").cloned().unwrap_or_default(), "name": name})), - _ => run(app, "master.renameLayout", json!({"name": name})), - } - } - ok || cancel - } + "comment" => dlg_comment(app, ui, d), + "bullets" => dlg_bullets(app, ui, d), + "action" => dlg_action(app, ui, d), + "altText" => dlg_alt_text(app, ui, d), + "outline" => dlg_outline(app, ui, d), + "reuseSlides" => dlg_reuse_slides(app, ui, d), + "renameSection" | "renameShape" | "renameLayout" => dlg_rename_section(app, ui, d), "font" => font_dialog(app, ui, d), "paragraph" => paragraph_dialog(app, ui, d), "tabs" => tabs_dialog(app, ui, d), - "export" => { - ui.label("Export the presentation as:"); - let fmt = d.get("fmt", "pdf"); - for (l, f) in [ - ("PDF document (.pdf)", "pdf"), - ("PNG images (current slide)", "png"), - ("PNG images (all slides)", "pngAll"), - ("JPEG image (current slide)", "jpeg"), - ("PowerPoint Presentation (.pptx)", "pptx"), - ("Outline (.txt)", "outline"), - ("Animated GIF (.gif)", "gif"), - ("Outline/RTF (.rtf)", "rtf"), - ] { - if ui.radio(fmt == f, l).clicked() { - d.fields.insert("fmt".into(), f.into()); - } - } - let pdf_layout = d.get("pdfLayout", "slides"); - if fmt == "pdf" { - ui.add_space(4.0); - ui.horizontal(|ui| { - let field = ui.label("Print layout:"); - let combo = egui::ComboBox::from_id_salt("pdf_layout") - .selected_text(match pdf_layout.as_str() { - "notes" => "Notes Pages", - "h1" => "Handouts (1 slide per page)", - "h2" => "Handouts (2 slides per page)", - "h3" => "Handouts (3 slides per page)", - "h4" => "Handouts (4 slides per page)", - "h6" => "Handouts (6 slides per page)", - "h9" => "Handouts (9 slides per page)", - _ => "Full Page Slides", - }) - .show_ui(ui, |ui| { - for (k, l) in [ - ("slides", "Full Page Slides"), - ("notes", "Notes Pages"), - ("h1", "Handouts (1 slide per page)"), - ("h2", "Handouts (2 slides per page)"), - ("h3", "Handouts (3 slides per page)"), - ("h4", "Handouts (4 slides per page)"), - ("h6", "Handouts (6 slides per page)"), - ("h9", "Handouts (9 slides per page)"), - ] { - if ui.selectable_label(pdf_layout == k, l).clicked() { - d.fields.insert("pdfLayout".into(), k.into()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - let mut hidden = d.get("pdfHidden", "false") == "true"; - if ui.checkbox(&mut hidden, "Include hidden slides").changed() { - d.fields.insert("pdfHidden".into(), hidden.to_string()); - } - } - let png = matches!(fmt.as_str(), "png" | "pngAll"); - if png { - ui.add_space(4.0); - let mut clear = d.get("pngTransparent", "false") == "true"; - if ui - .checkbox(&mut clear, "Transparent background") - .on_hover_text("Leave the slide background out: only what is on the slide is drawn") - .changed() - { - d.fields.insert("pngTransparent".into(), clear.to_string()); - } - } - let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) }; - if let Err(e) = &gif_params { - ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim)); - } - let pdf_params = { - let (layout, per) = match pdf_layout.as_str() { - "notes" => ("notes", 0), - l if l.starts_with('h') => ("handouts", l[1..].parse().unwrap_or(6)), - _ => ("slides", 0), - }; - json!({"layout": layout, "perPage": per, "includeHidden": d.get("pdfHidden", "false") == "true"}) - }; - let (ok, cancel) = buttons(ui, "Export"); - if ok { - let gif_params = match gif_params { - Ok(v) => v, - // Keep the dialog open so the range or width can be corrected. - Err(e) => { - app.set_status(e); - return false; - } - }; - let extra = match fmt.as_str() { - "pdf" => pdf_params, - "gif" => gif_params, - _ if png && d.get("pngTransparent", "false") == "true" => json!({"transparentBackground": true}), - _ => json!({}), - }; - app.export_as(&fmt, extra); - } - ok || cancel - } - "replaceFonts" => { - let used: Vec = - app.session.execute("font.used", &json!({})).ok().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); - let mut from = d.get("from", used.first().map(String::as_str).unwrap_or("")); - let mut to = d.get("to", ""); - egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - let field = ui.label("Replace:"); - let combo = egui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { - for f in &used { - ui.selectable_value(&mut from, f.clone(), f); - } - }); - let _ = combo.response.labelled_by(field.id); - ui.end_row(); - ui.label("With:"); - ui.vertical(|ui| { - ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name")); - let needle = to.trim().to_lowercase(); - egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| { - for f in - crate::ribbon::font_families(app).iter().filter(|f| needle.is_empty() || f.to_lowercase().contains(&needle)).take(200) - { - if ui.selectable_label(f.eq_ignore_ascii_case(to.trim()), f).clicked() { - to = f.clone(); - } - } - }); - }); - ui.end_row(); - }); - if let Some(msg) = d.fields.get("result") { - ui.label(egui::RichText::new(msg.clone()).color(Tokens::get(ui.ctx()).text_dim)); - } - d.fields.insert("from".into(), from.clone()); - d.fields.insert("to".into(), to.clone()); - let (ok, cancel) = buttons(ui, "Replace"); - if ok && !from.is_empty() && !to.trim().is_empty() { - let n = app.run("font.replace", json!({"from": from, "to": to.trim()})).ok().and_then(|v| v["replaced"].as_u64()).unwrap_or(0); - let msg = - if n == 0 { format!("โ€œ{from}โ€ isn't used explicitly.") } else { format!("Replaced โ€œ{from}โ€ with โ€œ{}โ€.", to.trim()) }; - d.fields.insert("result".into(), msg); - d.fields.remove("from"); - // Stay open, like PowerPoint, so several fonts can be replaced in a row. - return cancel; - } - cancel - } - "symbol" => { - ui.label("Click a symbol to insert it at the insertion point."); - let groups: [(&str, &str); 6] = [ - ("Punctuation", "โ€“โ€”โ€˜โ€™โ€œโ€โ€ฆโ€ขยทยงยถโ€ โ€กโ€ฐโ€ฒโ€ณโ€นโ€บยซยปยกยฟ"), - ("Currency", "$โ‚ฌยฃยฅยขโ‚นโ‚ฉโ‚ฝโ‚บโ‚ฟ"), - ("Math", "ยฑร—รทโ‰ โ‰ˆโ‰คโ‰ฅโˆžโˆšโˆ‘โˆโˆซโˆ‚โˆ†โˆ‡โˆˆโˆ‰โˆฉโˆชโŠ‚โŠƒโˆ€โˆƒยฌโˆงโˆจโ‡’โ‡”ยฐโ€ฒ"), - ("Greek", "ฮฑฮฒฮณฮดฮตฮถฮทฮธฮนฮบฮปฮผฮฝฮพฮฟฯ€ฯฯƒฯ„ฯ…ฯ†ฯ‡ฯˆฯ‰ฮ“ฮ”ฮ˜ฮ›ฮžฮ ฮฃฮฆฮจฮฉ"), - ("Arrows", "โ†โ†‘โ†’โ†“โ†”โ†•โ‡โ‡‘โ‡’โ‡“โ†–โ†—โ†˜โ†™โŸตโŸถ"), - ("Shapes", "โ– โ–กโ–ชโ–ซโ–ฒโ–ณโ–ถโ–ทโ–ผโ–ฝโ—€โ—โ—†โ—‡โ—‹โ—โ—Žโ˜…โ˜†โœ“โœ”โœ—โœ˜โ™ โ™ฃโ™ฅโ™ฆ"), - ]; - for (g, chars) in groups { - ui.label(egui::RichText::new(g).font(theme::bold(12.0))); - ui.horizontal_wrapped(|ui| { - for c in chars.chars() { - if ui.add(egui::Button::new(egui::RichText::new(c.to_string()).size(16.0)).min_size(vec2(26.0, 26.0))).clicked() { - if app.session.active().is_some_and(|s| s.selection.text.is_none()) { - let _ = app.run("text.edit", json!({})); - } - let _ = app.run("insert.symbol", json!({"text": c.to_string()})); - } - } - }); - } - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "equation" => { - ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-bยฑโˆš(b^2-4ac))/2a)."); - let mut v = d.get("eq", ""); - ui.add(egui::TextEdit::singleline(&mut v).hint_text("Equation").desired_width(340.0).font(theme::font(15.0))); - d.fields.insert("eq".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Insert"); - if ok && !v.is_empty() { - let pretty = pretty_equation(&v); - let size = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); - run(app, "insert.textBox", json!({"rect": [size.width / 2.0 - 150.0, size.height / 2.0 - 25.0, 300, 50], "text": pretty})); - run(app, "text.exit", json!({})); - run(app, "format.font", json!({"family": "Liberation Serif"})); - run(app, "format.italic", json!({"on": true})); - run(app, "format.size", json!({"size": 28})); - } - ok || cancel - } - "pasteSpecial" => { - const OPTS: [(&str, &str); 4] = [ - ("destinationTheme", "Use Destination Theme"), - ("keepSource", "Keep Source Formatting"), - ("picture", "Picture (PNG)"), - ("text", "Keep Text Only"), - ]; - let has_shapes = !app.session.clipboard.shapes.is_empty(); - let mut cur = d.get("as", "destinationTheme"); - ui.label("As:"); - for (id, label) in OPTS { - let ok = has_shapes || matches!(id, "destinationTheme" | "text"); - ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label)); - } - d.fields.insert("as".into(), cur.clone()); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run(app, "edit.pasteSpecial", json!({"as": cur})); - } - ok || cancel - } + "export" => dlg_export(app, ui, d), + "replaceFonts" => dlg_replace_fonts(app, ui, d), + "symbol" => dlg_symbol(app, ui, d), + "equation" => dlg_equation(app, ui, d), + "pasteSpecial" => dlg_paste_special(app, ui, d), "find" => find_dialog(app, ui, d), - "spelling" => { - // Words skipped with Ignore All while the dialog is open, one per line. - let ignored = d.get("ignore", ""); - let ignore: Vec<&str> = ignored.lines().filter(|l| !l.is_empty()).collect(); - let v = app.session.execute("review.spelling", &json!({"ignore": ignore})).unwrap_or_default(); - let list = v.as_array().cloned().unwrap_or_default(); - let info = app.session.execute("review.dictionary", &json!({})).unwrap_or_default(); - let available = info.get("available").and_then(Value::as_bool).unwrap_or(false); - match info.get("found").and_then(Value::as_str) { - Some(f) if available => { - ui.label(egui::RichText::new(format!("Dictionary: {}", f.rsplit(['/', '\\']).next().unwrap_or(f))).small().weak()) - } - _ if available => { - ui.label(egui::RichText::new(format!("Dictionary: {}", info.get("lang").and_then(Value::as_str).unwrap_or(""))).small().weak()) - } - _ => ui.label( - egui::RichText::new(format!( - "No {} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {}.", - info.get("lang").and_then(Value::as_str).unwrap_or("en-US"), - info.get("folders") - .and_then(Value::as_array) - .and_then(|a| a.get(1).or(a.first())) - .and_then(Value::as_str) - .unwrap_or("a dictionaries folder") - )) - .small() - .weak(), - ), - }; - if list.is_empty() { - ui.label("The spelling check is complete. No issues found."); - } - let mut add_ignore = None; - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for item in &list { - let w = item.get("word").and_then(Value::as_str).unwrap_or(""); - let slide = item.get("slide").cloned().unwrap_or_default(); - let shape = item.get("shape").cloned().unwrap_or_default(); - ui.horizontal(|ui| { - ui.label(format!("Slide {}: โ€œ{w}โ€", slide.as_u64().unwrap_or(0) + 1)); - if ui.small_button("Go to").clicked() { - let _ = app.run("slide.go", json!({"index": slide})); - let _ = app.run("edit.select", json!({"ids": [shape]})); - } - if ui.small_button("Ignore All").clicked() { - add_ignore = Some(w.to_string()); - } - if ui.small_button("Add to Dictionary").clicked() - && let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w})) - { - app.set_status(e.to_string()); - } - }); - let sug: Vec = item - .get("suggestions") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) - .unwrap_or_default(); - if item.get("repeated").and_then(Value::as_bool).unwrap_or(false) && !item.get("notes").and_then(Value::as_bool).unwrap_or(false) - { - ui.horizontal(|ui| { - ui.add_space(16.0); - ui.label(egui::RichText::new("Repeated word").weak()); - if ui.small_button("Delete Repeated Word").clicked() { - let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize; - let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize; - let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize; - let text = app.session.active().and_then(|st| { - let sh = st.shape(deckcraft_model::ShapeId(shape.as_u64()? as u32))?; - let pp = match (item.get("row").and_then(Value::as_u64), item.get("col").and_then(Value::as_u64), &sh.kind) { - (Some(r), Some(c), deckcraft_model::ShapeKind::Table(tb)) => { - tb.cell(r as usize, c as usize)?.text.paragraphs.get(para)?.text() - } - _ => sh.text.as_ref()?.paragraphs.get(para)?.text(), - }; - Some(pp.chars().skip(from).take(end.saturating_sub(from)).collect::()) - }); - if let Some(gap) = text { - let mut p = json!({"shape": shape, "para": para, "start": from, "word": gap, "to": ""}); - if let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { - o.insert("row".into(), r.clone()); - o.insert("col".into(), c.clone()); - } - run(app, "review.spellingChange", p); - } - } - }); - } - if !sug.is_empty() { - ui.horizontal_wrapped(|ui| { - ui.add_space(16.0); - ui.label(egui::RichText::new("Change to:").weak()); - for to in &sug { - let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all"); - if r.clicked() { - let all = ui.input(|i| i.modifiers.shift); - if item.get("notes").and_then(Value::as_bool).unwrap_or(false) { - // A word in the notes: change that line of the slide's notes. - let si = slide.as_u64().unwrap_or(0) as usize; - let notes = - app.session.active().and_then(|d| d.doc.slides.get(si)).map(|s| s.notes_text()).unwrap_or_default(); - let (pi, st) = ( - item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize, - item.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, - ); - let lines: Vec = notes - .split('\n') - .enumerate() - .map(|(i, l)| { - if i != pi { - return l.to_string(); - } - let c: Vec = l.chars().collect(); - let n = w.chars().count(); - if c.get(st..st + n).is_some_and(|x| x.iter().collect::() == w) { - c[..st].iter().chain(to.chars().collect::>().iter()).chain(c[st + n..].iter()).collect() - } else { - l.to_string() - } - }) - .collect(); - run(app, "slide.notes", json!({"text": lines.join("\n"), "index": si})); - } else { - let mut p = if all { - json!({"word": w, "to": to, "all": true}) - } else { - json!({"shape": shape, "word": w, "to": to, "para": item.get("para"), "start": item.get("start")}) - }; - if !all && let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { - o.insert("row".into(), r.clone()); - o.insert("col".into(), c.clone()); - } - run(app, "review.spellingChange", p); - } - } - } - }); - } - } - }); - if let Some(w) = add_ignore { - d.fields.insert("ignore".into(), format!("{ignored}{w}\n")); - } - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "accessibility" => { - let v = app.session.execute("review.accessibility", &json!({})).unwrap_or_default(); - let list = v.as_array().cloned().unwrap_or_default(); - if list.is_empty() { - ui.label("No accessibility issues found. People with disabilities should not have difficulty reading this document."); - } - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for item in &list { - ui.horizontal(|ui| { - ui.label(format!( - "Slide {}: {}{}", - item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1, - item.get("issue").and_then(Value::as_str).unwrap_or(""), - item.get("name").and_then(Value::as_str).map(|n| format!(" โ€” {n}")).unwrap_or_default() - )); - if ui.small_button("Go to").clicked() { - let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); - if let Some(id) = item.get("shape") { - let _ = app.run("edit.select", json!({"ids": [id]})); - } - } - }); - } - }); - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "language" => { - // Starts from the selected text's language and Do Not Check Spelling. - let (cur, np) = selected_language(app); - let lang = d.get("lang", &cur); - let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1"; - // The selected text, or every text in the presentation (all there is with nothing selected). - let has_sel = deckcraft_engine::cmd::has_text_or_shapes(&app.session).is_ok(); - let mut all = !has_sel || d.get("scope", "") == "all"; - ui.horizontal(|ui| { - ui.label("Apply to:"); - if ui.add_enabled(has_sel, egui::RadioButton::new(!all, "Selected text")).clicked() { - d.fields.insert("scope".into(), "sel".into()); - all = false; - } - if ui.radio(all, "Whole presentation").clicked() { - d.fields.insert("scope".into(), "all".into()); - all = true; - } - }); - ui.label(if all { "Mark all text in the presentation as:" } else { "Mark selected text as:" }); - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { - // The selection's own language first when the list doesn't have it. - let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone())); - let all = own.into_iter().chain(crate::squiggles::LANGS.iter().map(|(n, t)| (n.to_string(), t.to_string()))); - for (_native, tag) in all { - // Native names are shown when the interface fonts can draw them; otherwise - // the tag is the explicit fallback (egui 0.37 aborts on a missing glyph). - let name = crate::squiggles::display_lang_name(ui.ctx(), &tag); - let installed = deckcraft_engine::spell::dictionary(&tag).is_some(); - let label = if installed { format!("{name} (dictionary)") } else { name }; - if ui.selectable_label(lang.eq_ignore_ascii_case(&tag), label).clicked() { - d.fields.insert("lang".into(), tag); - } - } - }); - ui.add_space(4.0); - ui.label(egui::RichText::new("Spelling is checked for languages marked (dictionary).").small().weak()); - if ui.checkbox(&mut no_proof, "Do not check spelling or grammar").changed() { - d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() }); - } - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let lang = d.get("lang", &cur); - run(app, if all { "format.languageAll" } else { "format.language" }, json!({"lang": lang, "noProof": no_proof})); - } - ok || cancel - } - "customShow" => { - let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default(); - let mut started = false; - for s in &shows { - ui.horizontal(|ui| { - ui.label(format!("{} ({} slides)", s.name, s.slides.len())); - if ui.small_button("Show").clicked() { - run(app, "show.start", json!({"custom": s.name})); - started = true; - } - if ui.small_button("Delete").clicked() { - let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); - } - }); - } - if started { - return true; - } - field(ui, d, "New show name:", "name", "Custom Show 1"); - field(ui, d, "Slides (e.g. 1,3,5):", "slides", "1"); - let (ok, cancel) = buttons(ui, "Create"); - if ok { - let idx: Vec = - d.get("slides", "").split(',').filter_map(|s| s.trim().parse::().ok()).filter(|v| *v > 0).map(|v| v - 1).collect(); - run(app, "show.customShow", json!({"name": d.get("name", "Custom Show"), "slides": idx})); - } - ok || cancel - } - "chartData" => { - let ch = app.session.active().and_then(|s| { - s.selected_shapes() - .into_iter() - .find_map(|x| if let deckcraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None }) - }); - let Some((c, id)) = ch else { - ui.label("Select a chart first."); - let (ok, cancel) = buttons(ui, "Close"); - return ok || cancel; - }; - ui.label("Edit the data: first row = series names, first column = categories."); - let mut grid: Vec> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| { - let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::>()]; - for (i, cat) in c.categories.iter().enumerate() { - let mut row = vec![cat.clone()]; - for s in &c.series { - row.push(s.values.get(i).copied().flatten().map(|v| v.to_string()).unwrap_or_default()); - } - g.push(row); - } - g - }); - egui::Grid::new("chartdata").striped(true).show(ui, |ui| { - for row in grid.iter_mut() { - for cell in row.iter_mut() { - ui.add(egui::TextEdit::singleline(cell).hint_text("Chart cell").desired_width(70.0)); - } - ui.end_row(); - } - }); - ui.horizontal(|ui| { - if ui.button("+ Row").clicked() { - let n = grid.first().map(Vec::len).unwrap_or(2); - grid.push(vec![String::new(); n]); - } - if ui.button("+ Series").clicked() { - for r in grid.iter_mut() { - r.push(String::new()); - } - } - }); - d.params = json!({"grid": grid}); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let names: Vec = grid.first().map(|r| r.iter().skip(1).cloned().collect()).unwrap_or_default(); - let cats: Vec = grid.iter().skip(1).map(|r| r.first().cloned().unwrap_or_default()).collect(); - let series: Vec = names.iter().enumerate().map(|(k, n)| json!({"name": n, "values": grid.iter().skip(1).map(|r| r.get(k + 1).and_then(|v| v.trim().parse::().ok())).collect::>()})).collect(); - run(app, "chart.data", json!({"id": id.0, "categories": cats, "series": series})); - } - ok || cancel - } - "trim" => { - let seed = trim_seed(app.session.active()); - field(ui, d, "Start time (s):", "start", &seed.start); - field(ui, d, "End trim (s):", "end", &seed.end); - field(ui, d, "Fade in (s):", "fin", &seed.fade_in); - field(ui, d, "Fade out (s):", "fout", &seed.fade_out); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run( - app, - "media.options", - json!({ - "trimStart": seconds_text_to_ms(&d.get("start", &seed.start)), - "trimEnd": seconds_text_to_ms(&d.get("end", &seed.end)), - "fadeIn": seconds_text_to_ms(&d.get("fin", &seed.fade_in)), - "fadeOut": seconds_text_to_ms(&d.get("fout", &seed.fade_out)), - }), - ); - } - ok || cancel - } - "smartart" => { - ui.label("Pick a graphic; type one item per line."); - let kind = d.get("kind", "process"); - ui.horizontal_wrapped(|ui| { - for (k, l) in [ - ("list", "Basic List"), - ("process", "Basic Process"), - ("cycle", "Basic Cycle"), - ("hierarchy", "Hierarchy"), - ("pyramid", "Pyramid"), - ("matrix", "Matrix"), - ] { - if ui.selectable_label(kind == k, l).clicked() { - d.fields.insert("kind".into(), k.into()); - } - } - }); - let mut v = d.get("text", "Plan\nBuild\nLaunch"); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("SmartArt items").desired_rows(5).desired_width(320.0)); - d.fields.insert("text".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::>()})); - app.ui.pane = Some("smartart".into()); - } - ok || cancel - } - "about" => { - let tab = d.get("tab", "about"); - ui.horizontal(|ui| { - for (k, l) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")] { - if ui.selectable_label(tab == k, l).clicked() { - d.fields.insert("tab".into(), k.into()); - } - } - }); - ui.separator(); - match tab.as_str() { - "contributors" | "models" => { - credits_block(ui, tab == "models"); - } - _ => about(app, ui), - } - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "autocorrect" => { - let pr = &mut app.session.prefs; - ui.checkbox(&mut pr.ac_two_initials, "Correct TWo INitial CApitals"); - ui.checkbox(&mut pr.ac_sentence_caps, "Capitalize first letter of sentences"); - ui.checkbox(&mut pr.ac_days, "Capitalize names of days"); - ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key"); - ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (ยฝ)"); - ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript"); - ui.checkbox(&mut pr.ac_lists, "Automatic bulleted and numbered lists"); - ui.checkbox(&mut pr.autocorrect, "Replace text as you type"); - ui.add_space(4.0); - let mut from = d.get("from", ""); - let mut to = d.get("to", ""); - ui.horizontal(|ui| { - let l = ui.label("Replace"); - ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)).labelled_by(l.id); - let l = ui.label("With"); - ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)).labelled_by(l.id); - if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() { - run(app, "text.autocorrect", json!({"add": [from.trim(), to]})); - from.clear(); - to.clear(); - } - }); - d.fields.insert("from".into(), from.clone()); - d.fields.insert("to".into(), to); - let list = app.session.execute("text.autocorrect", &json!({})).ok().and_then(|v| v.get("entries").cloned()); - let entries: Vec<(String, String)> = list.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); - let q = from.trim().to_lowercase(); - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { - egui::Grid::new("ac-list").striped(true).show(ui, |ui| { - for (k, v) in entries.iter().filter(|(k, _)| q.is_empty() || k.to_lowercase().starts_with(&q)) { - ui.label(k); - ui.label(v); - if ui.small_button("Delete").clicked() { - run(app, "text.autocorrect", json!({"remove": k})); - } - ui.end_row(); - } - }); - }); - let (ok, cancel) = buttons(ui, "OK"); - ok || cancel - } + "spelling" => dlg_spelling(app, ui, d), + "accessibility" => dlg_accessibility(app, ui, d), + "language" => dlg_language(app, ui, d), + "customShow" => dlg_custom_show(app, ui, d), + "chartData" => dlg_chart_data(app, ui, d), + "trim" => dlg_trim(app, ui, d), + "smartart" => dlg_smartart(app, ui, d), + "about" => dlg_about(app, ui, d), + "autocorrect" => dlg_autocorrect(app, ui, d), "gridOptions" => grid_options(app, ui, d), "columns" => columns_dialog(app, ui, d), - "preferences" => { - // The controls apply as they change, so the dialog remembers what it opened with and - // Cancel (or Esc, or the close button: see `show`) puts that back. - if d.params.get("before").is_none() { - d.params = json!({ "before": preferences_snapshot(app) }); - } - ui.label(egui::RichText::new("General").font(theme::bold(13.0))); - let mut dark = app.ui.brightness == theme::Brightness::Dark; - if ui.checkbox(&mut dark, "Dark appearance").changed() { - let _ = app.run("view.dark", json!({"on": dark})); - } - // While the pointer holds the slider its value only waits in the dialog: applying it - // would rescale the slider under the pointer and run it to an end (#767). It applies - // on release, and at once from the keyboard. - let pending = d.fields.get("uiScale").and_then(|v| v.parse::().ok()); - let mut scale = pending.unwrap_or(app.ui.ui_scale); - let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75)); - // No visible label here (the row is laid out to fit); the accessible name only. - let enabled = r.enabled(); - r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), "Interface size")); - if r.is_pointer_button_down_on() { - if r.changed() || pending.is_some() { - d.fields.insert("uiScale".into(), scale.to_string()); - } - } else { - if r.changed() || pending.is_some() { - app.ui.ui_scale = scale; - } - d.fields.remove("uiScale"); - } - // PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here. - ui.horizontal(|ui| { - ui.label("Measurement units:"); - let units = &mut app.session.prefs.units; - ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches"); - ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters"); - }); - ui.label(egui::RichText::new("Editing").font(theme::bold(13.0))); - ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); - ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); - ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes"); - ui.horizontal(|ui| { - ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); - if ui.button("AutoCorrect Optionsโ€ฆ").clicked() { - *d = Dialog::new("autocorrect"); - } - }); - if d.id != "preferences" { - return false; - } - ui.horizontal(|ui| { - let label = ui.label("Your name (comments):"); - // Name the field for assistive technology; a bare text edit next to a label is an - // unnamed input in egui 0.37's accessibility tree. - let _ = ui.add(egui::TextEdit::singleline(&mut app.session.prefs.author)).labelled_by(label.id); - }); - let (ok, cancel) = buttons(ui, "Done"); - if cancel { - restore_preferences(app, d); - } - ok || cancel - } + "preferences" => dlg_preferences(app, ui, d), "photoAlbum" => photo_album_dialog(app, ui, d), "themeColors" => theme_colors_dialog(app, ui, d), "themeFonts" => theme_fonts_dialog(app, ui, d), @@ -1345,3 +72,1327 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } } + +fn run(app: &mut SlideApp, id: &str, p: Value) { + let _ = app.run(id, p); +} + +fn dlg_table(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + field(ui, d, "Number of columns:", "cols", "5"); + field(ui, d, "Number of rows:", "rows", "2"); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + let (r, c) = (num(d, "rows", 2.0) as usize, num(d, "cols", 5.0) as usize); + run(app, "insert.table", json!({"rows": r, "cols": c})); + } + ok || cancel +} + +fn dlg_slide_size(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); + let len = app.session.prefs.units; + let ppu = len.points_per_unit(); + // Each field keeps its exact size in points (`wPt`) beside the two decimals shown, so + // a preset, Portrait or an untouched field is not nudged off by the rounding (13.33", + // 33.87 cm read back would make the slide 959.76 or 960.09 pt wide). + if !d.fields.contains_key("wPt") { + put_slide_dim(d, "w", cur.width, ppu); + put_slide_dim(d, "h", cur.height, ppu); + } + let label = ui.label("Slides sized for:"); + let preset = d.get("preset", ""); + let combo = craft_ui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| { + for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES { + if ui.selectable_label(preset == *label, *label).clicked() { + d.fields.insert("preset".into(), label.to_string()); + put_slide_dim(d, "w", *w, ppu); + put_slide_dim(d, "h", *h, ppu); + } + } + }); + let _ = combo.response.labelled_by(label.id); + let abbr = unit_abbr(len); + let (w_seed, h_seed) = (field_of(d, "w"), field_of(d, "h")); + field(ui, d, &format!("Width ({abbr}):"), "w", &w_seed); + field(ui, d, &format!("Height ({abbr}):"), "h", &h_seed); + let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); + ui.horizontal(|ui| { + ui.label("Orientation:"); + let portrait = ui.radio(h > w, "Portrait").clicked(); + let landscape = ui.radio(w >= h, "Landscape").clicked(); + // Picking the other orientation swaps the width and height fields. + if portrait && w > h || landscape && h > w { + put_slide_dim(d, "w", h, ppu); + put_slide_dim(d, "h", w, ppu); + } + }); + let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); + run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }})); + } + ok || cancel +} + +fn dlg_header_footer(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default(); + let date = check(ui, d, "Date and time", "date", hf.date); + field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text); + // The format applies to an automatic date; a fixed one is the text as typed. + let automatic = d.get("dateText", &hf.date_text).is_empty(); + let fmt_now = if hf.date_format.is_empty() { "datetime1".to_string() } else { hf.date_format.clone() }; + // Not `d.get`: that stores the default as if the user had picked it. + let fmt = d.fields.get("dateFormat").cloned().unwrap_or_else(|| fmt_now.clone()); + let rows = date_rows("en-US"); + let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone()); + ui.add_enabled_ui(date && automatic, |ui| { + ui.horizontal(|ui| { + ui.label(" Format:"); + craft_ui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| { + for (kind, sample) in &rows { + if ui.selectable_label(fmt == *kind, sample).clicked() { + d.fields.insert("dateFormat".into(), (*kind).to_string()); + } + } + }); + }); + }); + let num_on = check(ui, d, "Slide number", "num", hf.slide_number); + let foot = check(ui, d, "Footer", "footer", hf.footer); + field(ui, d, " Footer text:", "footerText", &hf.footer_text); + let hide = check(ui, d, "Don't show on title slide", "hide", hf.hide_on_title); + ui.add_space(6.0); + let mut apply_all = false; + let mut apply = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + apply_all = ui.button("Apply to All").clicked(); + apply = ui.button("Apply").clicked(); + if ui.button("Cancel").clicked() { + d.fields.insert("cancel".into(), "1".into()); + } + }); + if apply || apply_all { + let dt = d.get("dateText", ""); + let ft = d.get("footerText", ""); + let mut params = serde_json::Map::new(); + params.insert("date".into(), json!(date)); + params.insert("dateText".into(), json!(dt)); + // Only a format the user picked: sending the default would rewrite every slide's + // existing date field (an imported datetime4, say) to datetime1. + if d.fields.contains_key("dateFormat") { + params.insert("dateFormat".into(), json!(d.get("dateFormat", &fmt_now))); + } + params.insert("slideNumber".into(), json!(num_on)); + params.insert("footer".into(), json!(foot)); + params.insert("footerText".into(), json!(ft)); + params.insert("hideOnTitle".into(), json!(hide)); + params.insert("all".into(), json!(apply_all)); + run(app, "design.headerFooter", serde_json::Value::Object(params)); + } + apply || apply_all || d.fields.contains_key("cancel") +} + +fn dlg_hyperlink(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let seed = hyperlink_seed_of(app.session.active()); + let slide_default = seed.slide.to_string(); + let mode = d.get("mode", seed.mode); + ui.horizontal(|ui| { + if ui.selectable_label(mode == "url", "Web Page or File").clicked() { + d.fields.insert("mode".into(), "url".into()); + } + if ui.selectable_label(mode == "slide", "This Document").clicked() { + d.fields.insert("mode".into(), "slide".into()); + } + if ui.selectable_label(mode == "email", "Email Address").clicked() { + d.fields.insert("mode".into(), "email".into()); + } + }); + match mode.as_str() { + "slide" => { + let titles: Vec = app + .session + .active() + .map(|s| s.doc.slides.iter().enumerate().map(|(i, sl)| format!("{}. {}", i + 1, sl.title())).collect()) + .unwrap_or_default(); + let sel = d.get("slide", &slide_default).parse::().unwrap_or(0); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + for (i, t) in titles.iter().enumerate() { + if ui.selectable_label(sel == i, t).clicked() { + d.fields.insert("slide".into(), i.to_string()); + } + } + }); + } + "email" => field(ui, d, "Email address:", "email", &seed.email), + _ => field(ui, d, "Address:", "url", &seed.url), + } + field(ui, d, "ScreenTip:", "tip", &seed.tip); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let mode_now = d.get("mode", seed.mode); + let url = d.get("url", &seed.url); + let email = d.get("email", &seed.email); + let slide = d.get("slide", &slide_default).parse::().unwrap_or(0); + let tip = d.get("tip", &seed.tip); + if hyperlink_should_apply(&seed, &mode_now, &url, &email, slide) { + run(app, "insert.hyperlink", hyperlink_command(&mode_now, &url, &email, slide, &tip, seed.highlight)); + } + } + ok || cancel +} + +fn dlg_setup_show(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let sh = app.session.active().map(|s| s.doc.show.clone()).unwrap_or_default(); + let ty = d.get("type", &sh.show_type); + ui.label("Show type"); + for (l, v) in [ + ("Presented by a speaker (full screen)", "speaker"), + ("Browsed by an individual (window)", "browsed"), + ("Browsed at a kiosk (full screen)", "kiosk"), + ] { + if ui.radio(ty == v, l).clicked() { + d.fields.insert("type".into(), v.into()); + } + } + let lp = check(ui, d, "Loop continuously until 'Esc'", "loop", sh.loop_until_esc); + let nn = check(ui, d, "Show without narration", "nn", sh.without_narration); + let na = check(ui, d, "Show without animation", "na", sh.without_animation); + let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); + let kc = check(ui, d, "Keep mouse cursor visible", "kc", app.ui.show_keep_cursor); + let names: Vec = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone(); + let field = ui.label("Slides"); + let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; + let combo = craft_ui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { + if ui.selectable_label(chosen.is_empty(), "All slides").clicked() { + d.fields.insert("custom".into(), String::new()); + } + for n in &names { + if ui.selectable_label(&chosen == n, n).clicked() { + d.fields.insert("custom".into(), n.clone()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let ty = d.get("type", "speaker"); + let custom = d.fields.get("custom").cloned().unwrap_or_default(); + run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut, "custom": custom})); + run(app, "show.keepCursor", json!({"on": kc})); + } + ok || cancel +} + +fn dlg_quit(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { + let names: Vec = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| d.title()).collect(); + if names.is_empty() { + app.quit_confirmed = true; + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + return true; + } + let what = if names.len() == 1 { format!("โ€œ{}โ€", names[0]) } else { format!("{} presentations", names.len()) }; + ui.label(egui::RichText::new(format!("Do you want to save the changes you made to {what}?")).strong()); + ui.label("Your changes will be lost if you don't save them."); + ui.add_space(8.0); + let mut done = false; + ui.horizontal(|ui| { + let t = Tokens::get(ui.ctx()); + if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { + let _ = app.session.execute("file.recovery.discard", &json!({})); + app.quit_confirmed = true; + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + done = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + 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)) { + if app.save_all() { + app.quit_confirmed = true; + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + done = true; + } + if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { + done = true; + } + }); + }); + done +} + +fn dlg_close(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { + // File โ€บ Close and Ctrl+W on a presentation with unsaved changes. + let Some(name) = app.session.active().map(|d| d.title()) else { return true }; + ui.label(egui::RichText::new(format!("Do you want to save the changes you made to โ€œ{name}โ€?")).strong()); + ui.label("Your changes will be lost if you don't save them."); + ui.add_space(8.0); + let mut done = false; + ui.horizontal(|ui| { + let t = Tokens::get(ui.ctx()); + if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { + run(app, "file.close", json!({})); + done = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + 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(); + if web || app.session.active().is_some_and(|d| !d.is_dirty()) { + run(app, "file.close", json!({})); + } + done = true; + } + if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { + done = true; + } + }); + }); + done +} + +fn dlg_comment(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let mut v = d.get("text", ""); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Comment").desired_rows(4).desired_width(320.0)); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Post"); + if ok && !v.trim().is_empty() { + run(app, "comment.add", json!({"text": v})); + app.ui.pane = Some("comments".into()); + } + ok || cancel +} + +fn dlg_bullets(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + // The current paragraph's list, as the dialog's starting point. + let cur = app.session.execute("format.state", &json!({})).ok().unwrap_or(Value::Null); + let numbered = cur.get("numbering").and_then(Value::as_bool).unwrap_or(false); + let tab = d.get("tab", if numbered { "numbered" } else { "bulleted" }); + ui.horizontal(|ui| { + for (k, l) in [("bulleted", "Bulleted"), ("numbered", "Numbered")] { + if ui.selectable_label(tab == k, l).clicked() { + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + let style_key = if tab == "numbered" { "scheme" } else { "char" }; + let current = d.get(style_key, if tab == "numbered" { "arabicPeriod" } else { "โ€ข" }); + let styles: &[(&str, &str)] = if tab == "numbered" { + &[ + ("arabicPeriod", "1. 2. 3."), + ("arabicParenR", "1) 2) 3)"), + ("romanUcPeriod", "I. II. III."), + ("romanLcPeriod", "i. ii. iii."), + ("alphaUcPeriod", "A. B. C."), + ("alphaLcParenR", "a) b) c)"), + ("alphaLcPeriod", "a. b. c."), + ] + } else { + &[("โ€ข", "โ€ข"), ("โ—‹", "โ—‹"), ("โ– ", "โ– "), ("โ–ก", "โ–ก"), ("โ—†", "โ—†"), ("โžข", "โžข"), ("โœ“", "โœ“"), ("โ€“", "โ€“"), ("โ˜…", "โ˜…")] + }; + egui::Grid::new("bullet-styles").show(ui, |ui| { + for (i, (k, l)) in styles.iter().enumerate() { + let r = ui.add_sized( + vec2(if tab == "numbered" { 88.0 } else { 44.0 }, 40.0), + egui::Button::selectable(current == *k, egui::RichText::new(*l).size(16.0)), + ); + if r.clicked() { + d.fields.insert(style_key.into(), (*k).into()); + } + if (i + 1) % if tab == "numbered" { 4 } else { 5 } == 0 { + ui.end_row(); + } + } + }); + if tab == "bulleted" { + field(ui, d, "Custom character:", "custom", ""); + } else { + field(ui, d, "Start at:", "start", "1"); + } + field(ui, d, "Size (% of text):", "size", "100"); + let colors = [ + ("", "Automatic"), + ("tx1", "Text"), + ("accent1", "Accent 1"), + ("accent2", "Accent 2"), + ("accent3", "Accent 3"), + ("accent4", "Accent 4"), + ("accent5", "Accent 5"), + ("accent6", "Accent 6"), + ]; + let color = d.get("color", ""); + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); + let combo = craft_ui::ComboBox::from_id_salt("bullet-color") + .selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1)) + .show_ui(ui, |ui| { + for (k, l) in colors { + if ui.selectable_label(color == k, l).clicked() { + d.fields.insert("color".into(), k.into()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + ui.horizontal(|ui| { + if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() { + d.fields.insert("none".into(), "1".into()); + } + }); + let (ok, cancel) = buttons(ui, "OK"); + let none = d.fields.contains_key("none"); + if none { + run(app, if tab == "numbered" { "format.numbering" } else { "format.bullets" }, json!({"on": false})); + } else if ok { + let mut p = json!({"on": true}); + if let Some(size) = d.get("size", "100").trim().trim_end_matches('%').parse::().ok().filter(|v| v.is_finite()) { + p["size"] = json!(size.clamp(25.0, 400.0)); + } + if !color.is_empty() { + p["color"] = json!(color); + } + if tab == "numbered" { + p["scheme"] = json!(d.get("scheme", "arabicPeriod")); + p["start"] = json!(d.get("start", "1").trim().parse::().unwrap_or(1).clamp(1, 32767)); + run(app, "format.numbering", p); + } else { + let custom = d.get("custom", ""); + let ch = custom.chars().find(|c| !c.is_whitespace()).map(String::from).unwrap_or_else(|| d.get("char", "โ€ข")); + p["char"] = json!(ch); + run(app, "format.bullets", p); + } + } + ok || cancel || none +} + +fn dlg_action(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + use deckcraft_model::text::Action; + let tab = d.get("tab", "click"); + // Start from the selected shape's current setting for this tab. + let (cur, slides, shows) = app + .session + .active() + .map(|st| { + let sh = st.selected_shapes().first().map(|x| if tab == "hover" { x.hover.clone() } else { x.click.clone() }).unwrap_or(None); + let slides: Vec<(deckcraft_model::SlideId, String)> = + st.doc.slides.iter().enumerate().map(|(i, sl)| (sl.id, format!("{}. {}", i + 1, sl.title()))).collect(); + let shows: Vec = st.doc.custom_shows.iter().map(|c| c.name.clone()).collect(); + (sh, slides, shows) + }) + .unwrap_or_default(); + let (cur_kind, cur_arg) = match cur.as_ref().map(|h| &h.action) { + None => ("none", String::new()), + Some(Action::NextSlide) => ("next", String::new()), + Some(Action::PreviousSlide) => ("previous", String::new()), + Some(Action::FirstSlide) => ("first", String::new()), + Some(Action::LastSlide) => ("last", String::new()), + Some(Action::LastViewed) => ("lastViewed", String::new()), + Some(Action::EndShow) => ("endShow", String::new()), + Some(Action::PlayMedia) => ("playMedia", String::new()), + Some(Action::Url { url }) => ("url", url.clone()), + Some(Action::CustomShow { name, .. }) => ("customShow", name.clone()), + Some(Action::Program { path }) => ("program", path.clone()), + Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()), + }; + ui.horizontal(|ui| { + for (k, l) in [("click", "Mouse Click"), ("hover", "Mouse Over")] { + if ui.selectable_label(tab == k, l).clicked() && tab != k { + d.fields.clear(); + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + ui.label(if tab == "hover" { "Action on mouse over" } else { "Action on click" }); + let kind = d.get("kind", cur_kind); + let hyperlink = !matches!(kind.as_str(), "none" | "program" | "playMedia"); + let pick = |d: &mut Dialog, ui: &mut Ui, k: &str, l: &str| { + if ui.radio(d.get("kind", cur_kind) == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + }; + pick(d, ui, "none", "None"); + ui.horizontal(|ui| { + let field = ui.radio(hyperlink, "Hyperlink to:"); + if field.clicked() && !hyperlink { + d.fields.insert("kind".into(), "next".into()); + } + let targets = [ + ("next", "Next Slide"), + ("previous", "Previous Slide"), + ("first", "First Slide"), + ("last", "Last Slide"), + ("lastViewed", "Last Slide Viewed"), + ("endShow", "End Show"), + ("customShow", "Custom Showโ€ฆ"), + ("slide", "Slideโ€ฆ"), + ("url", "URLโ€ฆ"), + ]; + let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1); + ui.add_enabled_ui(hyperlink, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| { + for (k, l) in targets { + if ui.selectable_label(kind == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + }); + let kind = d.get("kind", cur_kind); + match kind.as_str() { + "url" => field(ui, d, "Address:", "arg", if cur_kind == "url" { &cur_arg } else { "https://" }), + "slide" => { + let sel = d.get("arg", if cur_kind == "slide" { &cur_arg } else { "0" }).parse::().unwrap_or(0); + egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { + for (i, (_, t)) in slides.iter().enumerate() { + if ui.selectable_label(sel == i, t).clicked() { + d.fields.insert("arg".into(), i.to_string()); + } + } + }); + } + "customShow" => { + if shows.is_empty() { + ui.label("This presentation has no custom shows (Slide Show โ–ธ Custom Slide Show)."); + } + let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) }); + for n in &shows { + if ui.selectable_label(&sel == n, n).clicked() { + d.fields.insert("arg".into(), n.clone()); + } + } + } + _ => {} + } + pick(d, ui, "program", "Run program:"); + if d.get("kind", cur_kind) == "program" { + field(ui, d, "Program:", "arg", if cur_kind == "program" { &cur_arg } else { "" }); + } + pick(d, ui, "playMedia", "Play the shape's media"); + let hl = check(ui, d, "Highlight click", "highlight", cur.as_ref().is_none_or(|h| h.highlight_click)); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let kind = d.get("kind", cur_kind); + let arg = d.get("arg", &cur_arg); + let mut p = json!({"on": tab, "action": kind, "highlight": hl}); + match kind.as_str() { + "url" => p["url"] = json!(arg), + "slide" => p["slide"] = json!(arg.parse::().unwrap_or(0)), + "customShow" => p["show"] = json!(arg), + "program" => p["path"] = json!(arg), + _ => {} + } + run(app, "shape.action", p); + } + ok || cancel +} + +fn dlg_alt_text(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let (cur, deco_now) = app + .session + .active() + .map(|s| { + let shapes = s.selected_shapes(); + let cur = shapes.first().map(|x| x.descr.clone()).unwrap_or_default(); + let deco = alt_text_starts_decorative(shapes.iter().map(|x| x.decorative)); + (cur, deco) + }) + .unwrap_or_default(); + ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); + let mut v = d.get("text", &cur); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Alt text").desired_rows(4).desired_width(340.0)); + d.fields.insert("text".into(), v.clone()); + let deco = check(ui, d, "Mark as decorative", "deco", deco_now); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run(app, "shape.altText", json!({"text": v, "decorative": deco})); + } + ok || cancel +} + +fn dlg_outline(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets."); + let mut v = d.get("text", ""); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Outline").desired_rows(10).desired_width(380.0).code_editor()); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + run(app, "slide.fromOutline", json!({"text": v})); + } + ok || cancel +} + +fn dlg_reuse_slides(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string(); + let slides: Vec<(usize, String)> = d + .params + .get("slides") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|x| { + Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string())) + }) + .collect() + }) + .unwrap_or_default(); + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); + ui.label(format!("Insert slides from {name} after the current slide:")); + // Unticked slides are remembered by index; everything starts ticked. + let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1"); + ui.horizontal(|ui| { + if ui.small_button("Select All").clicked() { + for (i, _) in &slides { + d.fields.remove(&format!("off{i}")); + } + } + if ui.small_button("Select None").clicked() { + for (i, _) in &slides { + d.fields.insert(format!("off{i}"), "1".into()); + } + } + }); + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (i, title) in &slides { + let mut on = !off(d, *i); + let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) }; + if ui.checkbox(&mut on, label).changed() { + if on { + d.fields.remove(&format!("off{i}")); + } else { + d.fields.insert(format!("off{i}"), "1".into()); + } + } + } + }); + let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false); + if ui.checkbox(&mut keep, "Keep source formatting").changed() + && let Some(o) = d.params.as_object_mut() + { + o.insert("keep".into(), json!(keep)); + } + let picked: Vec = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect(); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + if picked.is_empty() { + app.set_status("Tick at least one slide to insert".to_string()); + return false; + } + // A failed insert keeps the dialog (and the ticked slides) open; the error is in the status bar. + let source = d.params.get("source").cloned().unwrap_or_default(); + return app.reuse_slides(&source, keep, &picked); + } + cancel +} + +fn dlg_rename_section(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string(); + field(ui, d, "Name:", "name", &cur); + let (ok, cancel) = buttons(ui, "Rename"); + if ok { + let name = d.get("name", ""); + match d.id.as_str() { + "renameSection" => run(app, "section.rename", json!({"index": d.params.get("index").cloned().unwrap_or_default(), "name": name})), + "renameShape" => run(app, "shape.rename", json!({"id": d.params.get("id").cloned().unwrap_or_default(), "name": name})), + _ => run(app, "master.renameLayout", json!({"name": name})), + } + } + ok || cancel +} + +fn dlg_export(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label("Export the presentation as:"); + let fmt = d.get("fmt", "pdf"); + for (l, f) in [ + ("PDF document (.pdf)", "pdf"), + ("PNG images (current slide)", "png"), + ("PNG images (all slides)", "pngAll"), + ("JPEG image (current slide)", "jpeg"), + ("PowerPoint Presentation (.pptx)", "pptx"), + ("Outline (.txt)", "outline"), + ("Animated GIF (.gif)", "gif"), + ("Outline/RTF (.rtf)", "rtf"), + ] { + if ui.radio(fmt == f, l).clicked() { + d.fields.insert("fmt".into(), f.into()); + } + } + let pdf_layout = d.get("pdfLayout", "slides"); + if fmt == "pdf" { + ui.add_space(4.0); + ui.horizontal(|ui| { + let field = ui.label("Print layout:"); + let combo = craft_ui::ComboBox::from_id_salt("pdf_layout") + .selected_text(match pdf_layout.as_str() { + "notes" => "Notes Pages", + "h1" => "Handouts (1 slide per page)", + "h2" => "Handouts (2 slides per page)", + "h3" => "Handouts (3 slides per page)", + "h4" => "Handouts (4 slides per page)", + "h6" => "Handouts (6 slides per page)", + "h9" => "Handouts (9 slides per page)", + _ => "Full Page Slides", + }) + .show_ui(ui, |ui| { + for (k, l) in [ + ("slides", "Full Page Slides"), + ("notes", "Notes Pages"), + ("h1", "Handouts (1 slide per page)"), + ("h2", "Handouts (2 slides per page)"), + ("h3", "Handouts (3 slides per page)"), + ("h4", "Handouts (4 slides per page)"), + ("h6", "Handouts (6 slides per page)"), + ("h9", "Handouts (9 slides per page)"), + ] { + if ui.selectable_label(pdf_layout == k, l).clicked() { + d.fields.insert("pdfLayout".into(), k.into()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + let mut hidden = d.get("pdfHidden", "false") == "true"; + if ui.checkbox(&mut hidden, "Include hidden slides").changed() { + d.fields.insert("pdfHidden".into(), hidden.to_string()); + } + } + let png = matches!(fmt.as_str(), "png" | "pngAll"); + if png { + ui.add_space(4.0); + let mut clear = d.get("pngTransparent", "false") == "true"; + if ui + .checkbox(&mut clear, "Transparent background") + .on_hover_text("Leave the slide background out: only what is on the slide is drawn") + .changed() + { + d.fields.insert("pngTransparent".into(), clear.to_string()); + } + } + let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) }; + if let Err(e) = &gif_params { + ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim)); + } + let pdf_params = { + let (layout, per) = match pdf_layout.as_str() { + "notes" => ("notes", 0), + l if l.starts_with('h') => ("handouts", l[1..].parse().unwrap_or(6)), + _ => ("slides", 0), + }; + json!({"layout": layout, "perPage": per, "includeHidden": d.get("pdfHidden", "false") == "true"}) + }; + let (ok, cancel) = buttons(ui, "Export"); + if ok { + let gif_params = match gif_params { + Ok(v) => v, + // Keep the dialog open so the range or width can be corrected. + Err(e) => { + app.set_status(e); + return false; + } + }; + let extra = match fmt.as_str() { + "pdf" => pdf_params, + "gif" => gif_params, + _ if png && d.get("pngTransparent", "false") == "true" => json!({"transparentBackground": true}), + _ => json!({}), + }; + app.export_as(&fmt, extra); + } + ok || cancel +} + +fn dlg_replace_fonts(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let used: Vec = app.session.execute("font.used", &json!({})).ok().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut from = d.get("from", used.first().map(String::as_str).unwrap_or("")); + let mut to = d.get("to", ""); + egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + let field = ui.label("Replace:"); + let combo = craft_ui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { + for f in &used { + ui.selectable_value(&mut from, f.clone(), f); + } + }); + let _ = combo.response.labelled_by(field.id); + ui.end_row(); + ui.label("With:"); + ui.vertical(|ui| { + ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name")); + let needle = to.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| { + for f in crate::ribbon::font_families(app).iter().filter(|f| needle.is_empty() || f.to_lowercase().contains(&needle)).take(200) { + if ui.selectable_label(f.eq_ignore_ascii_case(to.trim()), f).clicked() { + to = f.clone(); + } + } + }); + }); + ui.end_row(); + }); + if let Some(msg) = d.fields.get("result") { + ui.label(egui::RichText::new(msg.clone()).color(Tokens::get(ui.ctx()).text_dim)); + } + d.fields.insert("from".into(), from.clone()); + d.fields.insert("to".into(), to.clone()); + let (ok, cancel) = buttons(ui, "Replace"); + if ok && !from.is_empty() && !to.trim().is_empty() { + let n = app.run("font.replace", json!({"from": from, "to": to.trim()})).ok().and_then(|v| v["replaced"].as_u64()).unwrap_or(0); + let msg = if n == 0 { format!("โ€œ{from}โ€ isn't used explicitly.") } else { format!("Replaced โ€œ{from}โ€ with โ€œ{}โ€.", to.trim()) }; + d.fields.insert("result".into(), msg); + d.fields.remove("from"); + // Stay open, like PowerPoint, so several fonts can be replaced in a row. + return cancel; + } + cancel +} + +fn dlg_symbol(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { + ui.label("Click a symbol to insert it at the insertion point."); + let groups: [(&str, &str); 6] = [ + ("Punctuation", "โ€“โ€”โ€˜โ€™โ€œโ€โ€ฆโ€ขยทยงยถโ€ โ€กโ€ฐโ€ฒโ€ณโ€นโ€บยซยปยกยฟ"), + ("Currency", "$โ‚ฌยฃยฅยขโ‚นโ‚ฉโ‚ฝโ‚บโ‚ฟ"), + ("Math", "ยฑร—รทโ‰ โ‰ˆโ‰คโ‰ฅโˆžโˆšโˆ‘โˆโˆซโˆ‚โˆ†โˆ‡โˆˆโˆ‰โˆฉโˆชโŠ‚โŠƒโˆ€โˆƒยฌโˆงโˆจโ‡’โ‡”ยฐโ€ฒ"), + ("Greek", "ฮฑฮฒฮณฮดฮตฮถฮทฮธฮนฮบฮปฮผฮฝฮพฮฟฯ€ฯฯƒฯ„ฯ…ฯ†ฯ‡ฯˆฯ‰ฮ“ฮ”ฮ˜ฮ›ฮžฮ ฮฃฮฆฮจฮฉ"), + ("Arrows", "โ†โ†‘โ†’โ†“โ†”โ†•โ‡โ‡‘โ‡’โ‡“โ†–โ†—โ†˜โ†™โŸตโŸถ"), + ("Shapes", "โ– โ–กโ–ชโ–ซโ–ฒโ–ณโ–ถโ–ทโ–ผโ–ฝโ—€โ—โ—†โ—‡โ—‹โ—โ—Žโ˜…โ˜†โœ“โœ”โœ—โœ˜โ™ โ™ฃโ™ฅโ™ฆ"), + ]; + for (g, chars) in groups { + ui.label(egui::RichText::new(g).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + for c in chars.chars() { + if ui.add(egui::Button::new(egui::RichText::new(c.to_string()).size(16.0)).min_size(vec2(26.0, 26.0))).clicked() { + if app.session.active().is_some_and(|s| s.selection.text.is_none()) { + let _ = app.run("text.edit", json!({})); + } + let _ = app.run("insert.symbol", json!({"text": c.to_string()})); + } + } + }); + } + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel +} + +fn dlg_equation(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-bยฑโˆš(b^2-4ac))/2a)."); + let mut v = d.get("eq", ""); + ui.add(egui::TextEdit::singleline(&mut v).hint_text("Equation").desired_width(340.0).font(theme::font(15.0))); + d.fields.insert("eq".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok && !v.is_empty() { + let pretty = pretty_equation(&v); + let size = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); + run(app, "insert.textBox", json!({"rect": [size.width / 2.0 - 150.0, size.height / 2.0 - 25.0, 300, 50], "text": pretty})); + run(app, "text.exit", json!({})); + run(app, "format.font", json!({"family": "Liberation Serif"})); + run(app, "format.italic", json!({"on": true})); + run(app, "format.size", json!({"size": 28})); + } + ok || cancel +} + +fn dlg_paste_special(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + const OPTS: [(&str, &str); 4] = [ + ("destinationTheme", "Use Destination Theme"), + ("keepSource", "Keep Source Formatting"), + ("picture", "Picture (PNG)"), + ("text", "Keep Text Only"), + ]; + let has_shapes = !app.session.clipboard.shapes.is_empty(); + let mut cur = d.get("as", "destinationTheme"); + ui.label("As:"); + for (id, label) in OPTS { + let ok = has_shapes || matches!(id, "destinationTheme" | "text"); + ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label)); + } + d.fields.insert("as".into(), cur.clone()); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run(app, "edit.pasteSpecial", json!({"as": cur})); + } + ok || cancel +} + +fn dlg_spelling(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + // Words skipped with Ignore All while the dialog is open, one per line. + let ignored = d.get("ignore", ""); + let ignore: Vec<&str> = ignored.lines().filter(|l| !l.is_empty()).collect(); + let v = app.session.execute("review.spelling", &json!({"ignore": ignore})).unwrap_or_default(); + let list = v.as_array().cloned().unwrap_or_default(); + let info = app.session.execute("review.dictionary", &json!({})).unwrap_or_default(); + let available = info.get("available").and_then(Value::as_bool).unwrap_or(false); + match info.get("found").and_then(Value::as_str) { + Some(f) if available => ui.label(egui::RichText::new(format!("Dictionary: {}", f.rsplit(['/', '\\']).next().unwrap_or(f))).small().weak()), + _ if available => { + ui.label(egui::RichText::new(format!("Dictionary: {}", info.get("lang").and_then(Value::as_str).unwrap_or(""))).small().weak()) + } + _ => ui.label( + egui::RichText::new(format!( + "No {} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {}.", + info.get("lang").and_then(Value::as_str).unwrap_or("en-US"), + info.get("folders") + .and_then(Value::as_array) + .and_then(|a| a.get(1).or(a.first())) + .and_then(Value::as_str) + .unwrap_or("a dictionaries folder") + )) + .small() + .weak(), + ), + }; + if list.is_empty() { + ui.label("The spelling check is complete. No issues found."); + } + let mut add_ignore = None; + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for item in &list { + let w = item.get("word").and_then(Value::as_str).unwrap_or(""); + let slide = item.get("slide").cloned().unwrap_or_default(); + let shape = item.get("shape").cloned().unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(format!("Slide {}: โ€œ{w}โ€", slide.as_u64().unwrap_or(0) + 1)); + if ui.small_button("Go to").clicked() { + let _ = app.run("slide.go", json!({"index": slide})); + let _ = app.run("edit.select", json!({"ids": [shape]})); + } + if ui.small_button("Ignore All").clicked() { + add_ignore = Some(w.to_string()); + } + if ui.small_button("Add to Dictionary").clicked() + && let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w})) + { + app.set_status(e.to_string()); + } + }); + let sug: Vec = item + .get("suggestions") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + if item.get("repeated").and_then(Value::as_bool).unwrap_or(false) && !item.get("notes").and_then(Value::as_bool).unwrap_or(false) { + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new("Repeated word").weak()); + if ui.small_button("Delete Repeated Word").clicked() { + let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize; + let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize; + let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize; + let text = app.session.active().and_then(|st| { + let sh = st.shape(deckcraft_model::ShapeId(shape.as_u64()? as u32))?; + let pp = match (item.get("row").and_then(Value::as_u64), item.get("col").and_then(Value::as_u64), &sh.kind) { + (Some(r), Some(c), deckcraft_model::ShapeKind::Table(tb)) => { + tb.cell(r as usize, c as usize)?.text.paragraphs.get(para)?.text() + } + _ => sh.text.as_ref()?.paragraphs.get(para)?.text(), + }; + Some(pp.chars().skip(from).take(end.saturating_sub(from)).collect::()) + }); + if let Some(gap) = text { + let mut p = json!({"shape": shape, "para": para, "start": from, "word": gap, "to": ""}); + if let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { + o.insert("row".into(), r.clone()); + o.insert("col".into(), c.clone()); + } + run(app, "review.spellingChange", p); + } + } + }); + } + if !sug.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new("Change to:").weak()); + for to in &sug { + let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all"); + if r.clicked() { + let all = ui.input(|i| i.modifiers.shift); + if item.get("notes").and_then(Value::as_bool).unwrap_or(false) { + // A word in the notes: change that line of the slide's notes. + let si = slide.as_u64().unwrap_or(0) as usize; + let notes = app.session.active().and_then(|d| d.doc.slides.get(si)).map(|s| s.notes_text()).unwrap_or_default(); + let (pi, st) = ( + item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize, + item.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, + ); + let lines: Vec = notes + .split('\n') + .enumerate() + .map(|(i, l)| { + if i != pi { + return l.to_string(); + } + let c: Vec = l.chars().collect(); + let n = w.chars().count(); + if c.get(st..st + n).is_some_and(|x| x.iter().collect::() == w) { + c[..st].iter().chain(to.chars().collect::>().iter()).chain(c[st + n..].iter()).collect() + } else { + l.to_string() + } + }) + .collect(); + run(app, "slide.notes", json!({"text": lines.join("\n"), "index": si})); + } else { + let mut p = if all { + json!({"word": w, "to": to, "all": true}) + } else { + json!({"shape": shape, "word": w, "to": to, "para": item.get("para"), "start": item.get("start")}) + }; + if !all && let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { + o.insert("row".into(), r.clone()); + o.insert("col".into(), c.clone()); + } + run(app, "review.spellingChange", p); + } + } + } + }); + } + } + }); + if let Some(w) = add_ignore { + d.fields.insert("ignore".into(), format!("{ignored}{w}\n")); + } + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel +} + +fn dlg_accessibility(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { + let v = app.session.execute("review.accessibility", &json!({})).unwrap_or_default(); + let list = v.as_array().cloned().unwrap_or_default(); + if list.is_empty() { + ui.label("No accessibility issues found. People with disabilities should not have difficulty reading this document."); + } + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for item in &list { + ui.horizontal(|ui| { + ui.label(format!( + "Slide {}: {}{}", + item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1, + item.get("issue").and_then(Value::as_str).unwrap_or(""), + item.get("name").and_then(Value::as_str).map(|n| format!(" โ€” {n}")).unwrap_or_default() + )); + if ui.small_button("Go to").clicked() { + let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); + if let Some(id) = item.get("shape") { + let _ = app.run("edit.select", json!({"ids": [id]})); + } + } + }); + } + }); + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel +} + +fn dlg_language(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + // Starts from the selected text's language and Do Not Check Spelling. + let (cur, np) = selected_language(app); + let lang = d.get("lang", &cur); + let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1"; + // The selected text, or every text in the presentation (all there is with nothing selected). + let has_sel = deckcraft_engine::cmd::has_text_or_shapes(&app.session).is_ok(); + let mut all = !has_sel || d.get("scope", "") == "all"; + ui.horizontal(|ui| { + ui.label("Apply to:"); + if ui.add_enabled(has_sel, egui::RadioButton::new(!all, "Selected text")).clicked() { + d.fields.insert("scope".into(), "sel".into()); + all = false; + } + if ui.radio(all, "Whole presentation").clicked() { + d.fields.insert("scope".into(), "all".into()); + all = true; + } + }); + ui.label(if all { "Mark all text in the presentation as:" } else { "Mark selected text as:" }); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + // The selection's own language first when the list doesn't have it. + let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone())); + let all = own.into_iter().chain(crate::squiggles::LANGS.iter().map(|(n, t)| (n.to_string(), t.to_string()))); + for (_native, tag) in all { + // Native names are shown when the interface fonts can draw them; otherwise + // the tag is the explicit fallback (egui 0.37 aborts on a missing glyph). + let name = crate::squiggles::display_lang_name(ui.ctx(), &tag); + let installed = deckcraft_engine::spell::dictionary(&tag).is_some(); + let label = if installed { format!("{name} (dictionary)") } else { name }; + if ui.selectable_label(lang.eq_ignore_ascii_case(&tag), label).clicked() { + d.fields.insert("lang".into(), tag); + } + } + }); + ui.add_space(4.0); + ui.label(egui::RichText::new("Spelling is checked for languages marked (dictionary).").small().weak()); + if ui.checkbox(&mut no_proof, "Do not check spelling or grammar").changed() { + d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() }); + } + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let lang = d.get("lang", &cur); + run(app, if all { "format.languageAll" } else { "format.language" }, json!({"lang": lang, "noProof": no_proof})); + } + ok || cancel +} + +fn dlg_custom_show(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default(); + let mut started = false; + for s in &shows { + ui.horizontal(|ui| { + ui.label(format!("{} ({} slides)", s.name, s.slides.len())); + if ui.small_button("Show").clicked() { + run(app, "show.start", json!({"custom": s.name})); + started = true; + } + if ui.small_button("Delete").clicked() { + let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); + } + }); + } + if started { + return true; + } + field(ui, d, "New show name:", "name", "Custom Show 1"); + field(ui, d, "Slides (e.g. 1,3,5):", "slides", "1"); + let (ok, cancel) = buttons(ui, "Create"); + if ok { + let idx: Vec = + d.get("slides", "").split(',').filter_map(|s| s.trim().parse::().ok()).filter(|v| *v > 0).map(|v| v - 1).collect(); + run(app, "show.customShow", json!({"name": d.get("name", "Custom Show"), "slides": idx})); + } + ok || cancel +} + +fn dlg_chart_data(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let ch = app.session.active().and_then(|s| { + s.selected_shapes() + .into_iter() + .find_map(|x| if let deckcraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None }) + }); + let Some((c, id)) = ch else { + ui.label("Select a chart first."); + let (ok, cancel) = buttons(ui, "Close"); + return ok || cancel; + }; + ui.label("Edit the data: first row = series names, first column = categories."); + let mut grid: Vec> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| { + let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::>()]; + for (i, cat) in c.categories.iter().enumerate() { + let mut row = vec![cat.clone()]; + for s in &c.series { + row.push(s.values.get(i).copied().flatten().map(|v| v.to_string()).unwrap_or_default()); + } + g.push(row); + } + g + }); + egui::Grid::new("chartdata").striped(true).show(ui, |ui| { + for row in grid.iter_mut() { + for cell in row.iter_mut() { + ui.add(egui::TextEdit::singleline(cell).hint_text("Chart cell").desired_width(70.0)); + } + ui.end_row(); + } + }); + ui.horizontal(|ui| { + if ui.button("+ Row").clicked() { + let n = grid.first().map(Vec::len).unwrap_or(2); + grid.push(vec![String::new(); n]); + } + if ui.button("+ Series").clicked() { + for r in grid.iter_mut() { + r.push(String::new()); + } + } + }); + d.params = json!({"grid": grid}); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let names: Vec = grid.first().map(|r| r.iter().skip(1).cloned().collect()).unwrap_or_default(); + let cats: Vec = grid.iter().skip(1).map(|r| r.first().cloned().unwrap_or_default()).collect(); + let series: Vec = names.iter().enumerate().map(|(k, n)| json!({"name": n, "values": grid.iter().skip(1).map(|r| r.get(k + 1).and_then(|v| v.trim().parse::().ok())).collect::>()})).collect(); + run(app, "chart.data", json!({"id": id.0, "categories": cats, "series": series})); + } + ok || cancel +} + +fn dlg_trim(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let seed = trim_seed(app.session.active()); + field(ui, d, "Start time (s):", "start", &seed.start); + field(ui, d, "End trim (s):", "end", &seed.end); + field(ui, d, "Fade in (s):", "fin", &seed.fade_in); + field(ui, d, "Fade out (s):", "fout", &seed.fade_out); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run( + app, + "media.options", + json!({ + "trimStart": seconds_text_to_ms(&d.get("start", &seed.start)), + "trimEnd": seconds_text_to_ms(&d.get("end", &seed.end)), + "fadeIn": seconds_text_to_ms(&d.get("fin", &seed.fade_in)), + "fadeOut": seconds_text_to_ms(&d.get("fout", &seed.fade_out)), + }), + ); + } + ok || cancel +} + +fn dlg_smartart(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label("Pick a graphic; type one item per line."); + let kind = d.get("kind", "process"); + ui.horizontal_wrapped(|ui| { + for (k, l) in [ + ("list", "Basic List"), + ("process", "Basic Process"), + ("cycle", "Basic Cycle"), + ("hierarchy", "Hierarchy"), + ("pyramid", "Pyramid"), + ("matrix", "Matrix"), + ] { + if ui.selectable_label(kind == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + } + }); + let mut v = d.get("text", "Plan\nBuild\nLaunch"); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("SmartArt items").desired_rows(5).desired_width(320.0)); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::>()})); + app.ui.pane = Some("smartart".into()); + } + ok || cancel +} + +fn dlg_about(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let tab = d.get("tab", "about"); + ui.horizontal(|ui| { + for (k, l) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")] { + if ui.selectable_label(tab == k, l).clicked() { + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + match tab.as_str() { + "contributors" | "models" => { + credits_block(ui, tab == "models"); + } + _ => about(app, ui), + } + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel +} + +fn dlg_autocorrect(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let pr = &mut app.session.prefs; + ui.checkbox(&mut pr.ac_two_initials, "Correct TWo INitial CApitals"); + ui.checkbox(&mut pr.ac_sentence_caps, "Capitalize first letter of sentences"); + ui.checkbox(&mut pr.ac_days, "Capitalize names of days"); + ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key"); + ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (ยฝ)"); + ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript"); + ui.checkbox(&mut pr.ac_lists, "Automatic bulleted and numbered lists"); + ui.checkbox(&mut pr.autocorrect, "Replace text as you type"); + ui.add_space(4.0); + let mut from = d.get("from", ""); + let mut to = d.get("to", ""); + ui.horizontal(|ui| { + let l = ui.label("Replace"); + ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)).labelled_by(l.id); + let l = ui.label("With"); + ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)).labelled_by(l.id); + if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() { + run(app, "text.autocorrect", json!({"add": [from.trim(), to]})); + from.clear(); + to.clear(); + } + }); + d.fields.insert("from".into(), from.clone()); + d.fields.insert("to".into(), to); + let list = app.session.execute("text.autocorrect", &json!({})).ok().and_then(|v| v.get("entries").cloned()); + let entries: Vec<(String, String)> = list.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let q = from.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + egui::Grid::new("ac-list").striped(true).show(ui, |ui| { + for (k, v) in entries.iter().filter(|(k, _)| q.is_empty() || k.to_lowercase().starts_with(&q)) { + ui.label(k); + ui.label(v); + if ui.small_button("Delete").clicked() { + run(app, "text.autocorrect", json!({"remove": k})); + } + ui.end_row(); + } + }); + }); + let (ok, cancel) = buttons(ui, "OK"); + ok || cancel +} + +fn dlg_preferences(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + // The controls apply as they change, so the dialog remembers what it opened with and + // Cancel (or Esc, or the close button: see `show`) puts that back. + if d.params.get("before").is_none() { + d.params = json!({ "before": preferences_snapshot(app) }); + } + ui.label(egui::RichText::new("General").font(theme::bold(13.0))); + let mut dark = app.ui.brightness == theme::Brightness::Dark; + if ui.checkbox(&mut dark, "Dark appearance").changed() { + let _ = app.run("view.dark", json!({"on": dark})); + } + // While the pointer holds the slider its value only waits in the dialog: applying it + // would rescale the slider under the pointer and run it to an end (#767). It applies + // on release, and at once from the keyboard. + let pending = d.fields.get("uiScale").and_then(|v| v.parse::().ok()); + let mut scale = pending.unwrap_or(app.ui.ui_scale); + let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75)); + // No visible label here (the row is laid out to fit); the accessible name only. + let enabled = r.enabled(); + r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), "Interface size")); + if r.is_pointer_button_down_on() { + if r.changed() || pending.is_some() { + d.fields.insert("uiScale".into(), scale.to_string()); + } + } else { + if r.changed() || pending.is_some() { + app.ui.ui_scale = scale; + } + d.fields.remove("uiScale"); + } + // PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here. + ui.horizontal(|ui| { + ui.label("Measurement units:"); + let units = &mut app.session.prefs.units; + ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches"); + ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters"); + }); + ui.label(egui::RichText::new("Editing").font(theme::bold(13.0))); + ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); + ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); + ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes"); + ui.horizontal(|ui| { + ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); + if ui.button("AutoCorrect Optionsโ€ฆ").clicked() { + *d = Dialog::new("autocorrect"); + } + }); + if d.id != "preferences" { + return false; + } + ui.horizontal(|ui| { + let label = ui.label("Your name (comments):"); + // Name the field for assistive technology; a bare text edit next to a label is an + // unnamed input in egui 0.37's accessibility tree. + let _ = ui.add(egui::TextEdit::singleline(&mut app.session.prefs.author)).labelled_by(label.id); + }); + let (ok, cancel) = buttons(ui, "Done"); + if cancel { + restore_preferences(app, d); + } + ok || cancel +} diff --git a/crates/ui-egui/src/dialogs/columns.rs b/crates/ui-egui/src/dialogs/columns.rs index 3e40916d..f436fce6 100644 --- a/crates/ui-egui/src/dialogs/columns.rs +++ b/crates/ui-egui/src/dialogs/columns.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, Dialog, SlideApp, Ui, Value, buttons, field, field_of, json, num_text}; /// The Columns dialog's changed fields as `format.columns` params: Number and Spacing (inches, /// sent in points). A field left as the dialog showed it is left out, so each selected text box diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index 0cef384b..b133efe1 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -223,7 +223,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { } ui.separator(); let field = ui.label("Sort"); - let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, k.label().0).clicked() { v.key = k; diff --git a/crates/ui-egui/src/dialogs/date_time.rs b/crates/ui-egui/src/dialogs/date_time.rs index 2f78dbc3..3ba1c3d7 100644 --- a/crates/ui-egui/src/dialogs/date_time.rs +++ b/crates/ui-egui/src/dialogs/date_time.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DATE_KINDS, Dialog, SlideApp, Tokens, Ui, buttons, check, json}; const DATE_LANGS: &[(&str, &str)] = &[ ("en-US", "English (United States)"), @@ -49,7 +49,7 @@ pub(super) fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) let lang_label = DATE_LANGS.iter().find(|(k, _)| *k == lang).map(|(_, l)| *l).unwrap_or("English (United States)"); ui.horizontal(|ui| { ui.label("Language:"); - let opened = egui::ComboBox::from_id_salt("date-lang").selected_text(lang_label).width(240.0).show_ui(ui, |ui| { + let opened = craft_ui::ComboBox::from_id_salt("date-lang").selected_text(lang_label).width(240.0).show_ui(ui, |ui| { for (k, l) in DATE_LANGS { if ui.selectable_label(lang == *k, *l).clicked() { d.fields.insert("language".into(), (*k).to_string()); diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs index d69e247c..432ac58e 100644 --- a/crates/ui-egui/src/dialogs/export.rs +++ b/crates/ui-egui/src/dialogs/export.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, Value, check, field, json, num, vec2}; /// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden /// slides) as `file.export` params; a slide range that doesn't fit the deck is an error message. @@ -16,7 +16,7 @@ pub(super) fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result ui.add_space(4.0); ui.horizontal(|ui| { let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:")); - let combo = egui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| { for k in SIZE_PRESETS.iter().map(|(k, _)| *k).chain(["custom"]) { if ui.selectable_label(size == k, size_label(k)).clicked() { d.fields.insert("gifSize".into(), k.into()); diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs index c9a04f66..c702db92 100644 --- a/crates/ui-egui/src/dialogs/find.rs +++ b/crates/ui-egui/src/dialogs/find.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, Value, check, field, json, vec2}; /// Home โ€บ Find / Replace: step through matches (slide text, table cells, notes) or replace them. pub(super) fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { diff --git a/crates/ui-egui/src/dialogs/font.rs b/crates/ui-egui/src/dialogs/font.rs index 7e2eabf7..a163e7bc 100644 --- a/crates/ui-egui/src/dialogs/font.rs +++ b/crates/ui-egui/src/dialogs/font.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, buttons, check, choice, color_label, finite_pt, json, json_text, labeled_text, seed_once, vec2}; fn seed_font_fields(d: &mut Dialog) { seed_once(d, "family", json_text(d.params.get("family"), "")); @@ -26,7 +26,7 @@ pub(super) fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo } ui.horizontal(|ui| { let field = ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); - let combo = egui::ComboBox::from_id_salt("font_dialog_family") + let combo = craft_ui::ComboBox::from_id_salt("font_dialog_family") .selected_text(if family.is_empty() { " " } else { family.as_str() }) .width(300.0) .show_ui(ui, |ui| { @@ -75,7 +75,7 @@ pub(super) fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo let mut spacing = d.get("spacing", "0"); let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt")); let mut picked = None; - let combo = egui::ComboBox::from_id_salt("font_dialog_spacing") + let combo = craft_ui::ComboBox::from_id_salt("font_dialog_spacing") .selected_text(match spacing.as_str() { "-3" => "Very tight", "-1.5" => "Tight", diff --git a/crates/ui-egui/src/dialogs/grid_options.rs b/crates/ui-egui/src/dialogs/grid_options.rs index f90442f3..0e7f9c97 100644 --- a/crates/ui-egui/src/dialogs/grid_options.rs +++ b/crates/ui-egui/src/dialogs/grid_options.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, Dialog, SlideApp, Ui, buttons, check, field, json, num, theme}; fn format_spacing(v: f64) -> String { if !v.is_finite() { diff --git a/crates/ui-egui/src/dialogs/hyperlink.rs b/crates/ui-egui/src/dialogs/hyperlink.rs index dd9be289..a2882600 100644 --- a/crates/ui-egui/src/dialogs/hyperlink.rs +++ b/crates/ui-egui/src/dialogs/hyperlink.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Value, json}; /// What Insert Hyperlink would edit on the current selection. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/ui-egui/src/dialogs/language.rs b/crates/ui-egui/src/dialogs/language.rs index 57536fb7..11b941c5 100644 --- a/crates/ui-egui/src/dialogs/language.rs +++ b/crates/ui-egui/src/dialogs/language.rs @@ -1,4 +1,4 @@ -use super::*; +use super::SlideApp; /// The selection's proofing language and whether it's marked Do Not Check Spelling: the run at /// the caret, else the first run of the first selected shape; US English when none says. diff --git a/crates/ui-egui/src/dialogs/paragraph.rs b/crates/ui-egui/src/dialogs/paragraph.rs index d442c825..217076c3 100644 --- a/crates/ui-egui/src/dialogs/paragraph.rs +++ b/crates/ui-egui/src/dialogs/paragraph.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, Value, buttons, choice, finite_pt, inches_text, json, json_text, labeled_text, pt_text, seed_once, vec2}; fn seed_paragraph_fields(d: &mut Dialog) { seed_once(d, "align", json_text(d.params.get("align"), "left")); @@ -103,19 +103,20 @@ pub(super) fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) let field = ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); let kind = d.get("lineKind", "multiple"); let value = d.get("lineValue", "1"); - let combo = egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { - for (label, k, v) in [ - ("Single", "multiple", Some("1")), - ("1.5 lines", "multiple", Some("1.5")), - ("Double", "multiple", Some("2")), - ("Multiple", "multiple", None), - ("Exactly", "exactly", None), - ] { - if ui.selectable_label(line_preset_label(&kind, &value) == label, label).clicked() { - picked = Some((k, v)); + let combo = + craft_ui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { + for (label, k, v) in [ + ("Single", "multiple", Some("1")), + ("1.5 lines", "multiple", Some("1.5")), + ("Double", "multiple", Some("2")), + ("Multiple", "multiple", None), + ("Exactly", "exactly", None), + ] { + if ui.selectable_label(line_preset_label(&kind, &value) == label, label).clicked() { + picked = Some((k, v)); + } } - } - }); + }); let _ = combo.response.labelled_by(field.id); ui.add_space(8.0); let _ = labeled_text(ui, d, "At", "lineValue", 64.0); diff --git a/crates/ui-egui/src/dialogs/photo_album.rs b/crates/ui-egui/src/dialogs/photo_album.rs index 84c6f5b9..340e5832 100644 --- a/crates/ui-egui/src/dialogs/photo_album.rs +++ b/crates/ui-egui/src/dialogs/photo_album.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Tokens, Ui, Value, buttons, check, field, json}; fn album_items(d: &Dialog) -> Option> { let raw = d.fields.get("items").map(String::as_str).unwrap_or("[]"); @@ -69,7 +69,7 @@ fn pick_album_picture(app: &mut SlideApp) -> Option { fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, name: &str, key: &str, default: &str, choices: &[(&str, &str)]) { let cur = d.get(key, default); let label = choices.iter().find(|(k, _)| *k == cur).map(|(_, l)| *l).unwrap_or(default); - let opened = egui::ComboBox::from_id_salt(id).selected_text(label).width(220.0).show_ui(ui, |ui| { + let opened = craft_ui::ComboBox::from_id_salt(id).selected_text(label).width(220.0).show_ui(ui, |ui| { for (k, l) in choices { if ui.selectable_label(cur == *k, *l).clicked() { d.fields.insert(key.into(), (*k).to_string()); diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 13e96203..b0b98fe5 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Value, json, theme}; /// What the Preferences dialog can change, as it was when the dialog opened. pub(super) fn preferences_snapshot(app: &SlideApp) -> Value { diff --git a/crates/ui-egui/src/dialogs/size_position.rs b/crates/ui-egui/src/dialogs/size_position.rs index 8745203f..73e6274f 100644 --- a/crates/ui-egui/src/dialogs/size_position.rs +++ b/crates/ui-egui/src/dialogs/size_position.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, Value, buttons, check, edited, exact_of, field_of, json, num_text, parse_shown, text_row}; fn put_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) { d.fields.insert(key.into(), shown.clone()); diff --git a/crates/ui-egui/src/dialogs/slide_size.rs b/crates/ui-egui/src/dialogs/slide_size.rs index 93e1e3bf..a69a478e 100644 --- a/crates/ui-egui/src/dialogs/slide_size.rs +++ b/crates/ui-egui/src/dialogs/slide_size.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, exact_of, field_of}; /// A Slide Size field set to `pt`: two decimals of the unit shown, the exact points kept beside. pub(super) fn put_slide_dim(d: &mut Dialog, key: &str, pt: f64, ppu: f64) { diff --git a/crates/ui-egui/src/dialogs/tabs.rs b/crates/ui-egui/src/dialogs/tabs.rs index c0fa1478..a74760c5 100644 --- a/crates/ui-egui/src/dialogs/tabs.rs +++ b/crates/ui-egui/src/dialogs/tabs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, Value, buttons, field, json, num, vec2}; const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]; diff --git a/crates/ui-egui/src/dialogs/theme_colors.rs b/crates/ui-egui/src/dialogs/theme_colors.rs index fb00f510..250dfea4 100644 --- a/crates/ui-egui/src/dialogs/theme_colors.rs +++ b/crates/ui-egui/src/dialogs/theme_colors.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, CornerRadius, Dialog, SchemeSlot, Sense, SlideApp, Stroke, Tokens, Ui, Value, buttons, json, theme, vec2}; fn seed_theme_colors(d: &mut Dialog) { if d.fields.contains_key("seeded") { diff --git a/crates/ui-egui/src/dialogs/theme_fonts.rs b/crates/ui-egui/src/dialogs/theme_fonts.rs index 0bf15ff0..0adcd337 100644 --- a/crates/ui-egui/src/dialogs/theme_fonts.rs +++ b/crates/ui-egui/src/dialogs/theme_fonts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Tokens, Ui, Value, buttons, json, vec2}; fn seed_theme_fonts(d: &mut Dialog) { if d.fields.contains_key("seeded") { @@ -30,7 +30,7 @@ fn theme_font_choices(app: &SlideApp, selected: &str) -> Vec { /// Draw an accessible font combo box and update the selected family; the caller supplies the /// visible label. fn font_combo(ui: &mut Ui, label: &str, choices: &[String], selected: &mut String) { - let resp = egui::ComboBox::from_id_salt(label).selected_text(selected.as_str()).width(240.0).show_ui(ui, |ui| { + let resp = craft_ui::ComboBox::from_id_salt(label).selected_text(selected.as_str()).width(240.0).show_ui(ui, |ui| { for face in choices { if ui.selectable_label(selected == face, face).clicked() { *selected = face.clone(); diff --git a/crates/ui-egui/src/dialogs/zoom.rs b/crates/ui-egui/src/dialogs/zoom.rs index 9bf392bf..e257b9f9 100644 --- a/crates/ui-egui/src/dialogs/zoom.rs +++ b/crates/ui-egui/src/dialogs/zoom.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, buttons}; /// Preset percents in the Zoom dialog, largest first. const ZOOM_PRESETS: &[u32] = &[400, 200, 150, 100, 75, 66, 50, 33]; diff --git a/crates/ui-egui/src/doc_window.rs b/crates/ui-egui/src/doc_window.rs index dfff3d4b..44c739ae 100644 --- a/crates/ui-egui/src/doc_window.rs +++ b/crates/ui-egui/src/doc_window.rs @@ -14,7 +14,7 @@ use crate::SlideApp; pub struct DocWindow { pub uid: u64, pub slide: usize, - key: Option<(u64, u64, usize, u32, u32, bool)>, + key: Option, tex: Option, } @@ -108,7 +108,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin::same(16))).show(ui, |ui| { let avail = ui.available_rect_before_wrap(); let (w, h) = pixel_size(&doc, avail.width()); - let tex = ensure_texture(app, ui.ctx(), &doc, uid, revision, slide, w, h, grayscale); + let tex = ensure_texture(app, ui.ctx(), &doc, (uid, revision, slide, w, h, grayscale)); if let Some(tex) = tex { let fitted = fit_rect(avail, w, h); ui.painter().rect_filled(fitted, CornerRadius::ZERO, Color32::WHITE); @@ -141,18 +141,11 @@ fn fit_rect(avail: Rect, w: u32, h: u32) -> Rect { Rect::from_center_size(avail.center(), vec2(width.max(1.0), height.max(1.0))) } -fn ensure_texture( - app: &mut SlideApp, - ctx: &egui::Context, - doc: &deckcraft_model::Presentation, - uid: u64, - revision: u64, - slide: usize, - w: u32, - h: u32, - grayscale: bool, -) -> Option { - let key = (uid, revision, slide, w, h, grayscale); +/// What a cached slide texture was drawn from: document, revision, slide, pixel size, grayscale. +type TexKey = (u64, u64, usize, u32, u32, bool); + +fn ensure_texture(app: &mut SlideApp, ctx: &egui::Context, doc: &deckcraft_model::Presentation, key: TexKey) -> Option { + let (_, _, slide, w, h, grayscale) = key; if let Some(win) = app.doc_window.as_ref() && win.key == Some(key) && let Some(tex) = win.tex.clone() diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs index 19a0a584..e4479547 100644 --- a/crates/ui-egui/src/icons/draw.rs +++ b/crates/ui-egui/src/icons/draw.rs @@ -1,19 +1,26 @@ use super::geometry::{B, C, D, G, Glyph, K, N, O, P2, PU, Pen, R, W, Y, arc_pts, cubic_pts, quad_pts, rad, round_pts, rrect_pts, t, tf}; -use super::*; +use super::{Icon, PI}; // --------------------------------------------------------------------------------------------- // The icons // --------------------------------------------------------------------------------------------- -#[expect(clippy::too_many_lines)] pub(super) fn draw(p: &Pen<'_>, icon: Icon) { + let _ = draw_clipboard_to_drawing(p, icon) + || draw_insert_to_design(p, icon) + || draw_motion_to_view(p, icon) + || draw_arrange_to_media(p, icon) + || draw_chrome_and_status(p, icon); +} + +fn draw_clipboard_to_drawing(p: &Pen<'_>, icon: Icon) -> bool { use Icon as I; match icon { // ----- Clipboard & slides ----- I::Paste => { p.rline(&[(10.5, 21.5), (3.5, 21.5), (3.5, 4.5), (17.0, 4.5), (17.0, 9.0)], 2.0, K); - p.rect(7.0, 2.5, 13.5, 6.5, 1.2, Y, K); - p.rect(10.5, 10.0, 21.0, 22.0, 1.2, t(B), B); + p.rect((7.0, 2.5, 13.5, 6.5), 1.2, Y, K); + p.rect((10.5, 10.0, 21.0, 22.0), 1.2, t(B), B); p.seg((13.0, 14.0), (18.5, 14.0), B); p.seg((13.0, 18.0), (17.0, 18.0), B); } @@ -26,14 +33,14 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Copy => { p.rline(&[(8.0, 16.0), (3.0, 16.0), (3.0, 2.5), (14.0, 2.5), (14.0, 7.0)], 1.5, K); - p.rect(8.0, 7.5, 20.5, 21.5, 1.5, t(B), B); + p.rect((8.0, 7.5, 20.5, 21.5), 1.5, t(B), B); p.seg((11.0, 12.5), (17.5, 12.5), B); p.seg((11.0, 16.5), (16.0, 16.5), B); } I::FormatPainter => { - p.rect(2.5, 3.0, 17.0, 9.0, 1.5, Y, K); + p.rect((2.5, 3.0, 17.0, 9.0), 1.5, Y, K); p.rline(&[(17.0, 6.0), (20.5, 6.0), (20.5, 11.5), (11.0, 11.5), (11.0, 14.0)], 1.2, K); - p.rect(9.0, 14.0, 13.0, 21.5, 1.0, K, K); + p.rect((9.0, 14.0, 13.0, 21.5), 1.0, K, K); } I::NewSlide => { p.frame(2.0, 4.0, 19.5, 16.5); @@ -43,9 +50,9 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Layout => { p.frame(2.0, 3.5, 22.0, 20.5); - p.rect(5.0, 6.5, 19.0, 9.0, 0.8, B, N); - p.rect(5.0, 11.5, 11.0, 17.5, 0.8, t(G), G); - p.rect(13.0, 11.5, 19.0, 17.5, 0.8, t(O), O); + p.rect((5.0, 6.5, 19.0, 9.0), 0.8, B, N); + p.rect((5.0, 11.5, 11.0, 17.5), 0.8, t(G), G); + p.rect((13.0, 11.5, 19.0, 17.5), 0.8, t(O), O); } I::ResetSlide => { p.frame(2.0, 3.5, 22.0, 20.5); @@ -54,12 +61,12 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { I::Section => { p.rshape(&[(2.5, 2.5), (13.0, 2.5), (16.5, 5.75), (13.0, 9.0), (2.5, 9.0)], 0.6, PU, N); p.rline(&[(5.0, 9.0), (5.0, 16.0), (7.5, 16.0)], 1.0, K); - p.rect(7.5, 12.0, 21.5, 20.5, 1.2, N, K); + p.rect((7.5, 12.0, 21.5, 20.5), 1.2, N, K); p.seg((10.0, 15.5), (16.0, 15.5), K.a(0.5)); } I::DuplicateSlide => { p.rline(&[(6.5, 15.5), (2.5, 15.5), (2.5, 3.5), (16.5, 3.5), (16.5, 8.0)], 1.5, K); - p.rect(6.5, 8.0, 21.5, 20.5, 1.5, t(B), B); + p.rect((6.5, 8.0, 21.5, 20.5), 1.5, t(B), B); p.seg((14.0, 11.5), (14.0, 17.0), B); p.seg((11.25, 14.25), (16.75, 14.25), B); } @@ -70,14 +77,14 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.badge((18.0, 17.5), 5.0, R, Glyph::Cross); } I::HideSlide => { - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K.a(0.55)); + p.rect((2.0, 4.0, 22.0, 20.0), 1.5, N, K.a(0.55)); p.seg((5.0, 8.0), (13.0, 8.0), K.a(0.55)); p.seg((3.0, 21.0), (21.0, 3.0), R); } I::Slide => { p.frame(2.0, 4.5, 22.0, 19.5); p.seg((5.0, 8.5), (15.0, 8.5), B); - p.rect(5.0, 11.5, 19.0, 16.5, 0.8, t(B), N); + p.rect((5.0, 11.5, 19.0, 16.5), 0.8, t(B), N); } // ----- Font ----- @@ -136,11 +143,11 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { let m = |pts: &[P2]| tf((8.0, 15.0), -45.0, pts); p.poly(&m(&[(0.0, -1.2), (3.0, -3.0), (3.0, 3.0), (0.0, 1.4)]), Y, K); p.poly(&m(&[(3.0, -3.0), (13.5, -3.0), (13.5, 3.0), (3.0, 3.0)]), N, K); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, Y, N); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, Y, N); } I::FontColor => { p.letter_a(4.5, 2.5, 19.5, 17.0, K, 1.1); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, R, N); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, R, N); } I::TextShadow => { p.text("S", (14.0, 13.8), 22.0, K.a(0.3)); @@ -201,7 +208,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.arrow((21.0, 3.5), (21.0, 20.5), B); } I::AlignText => { - p.rect(2.5, 3.0, 15.0, 21.0, 1.2, N, K); + p.rect((2.5, 3.0, 15.0, 21.0), 1.2, N, K); p.seg((5.5, 10.0), (12.0, 10.0), K); p.seg((5.5, 14.0), (10.5, 14.0), K); p.arrow((19.5, 2.5), (19.5, 9.5), B); @@ -212,27 +219,27 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg((2.5, y), (x1, y), K); } p.path_arrow(&[(14.0, 6.0), (19.0, 6.0), (19.0, 12.5)], B); - p.rect(2.5, 15.5, 7.5, 21.5, 1.0, B, N); - p.rect(9.5, 15.5, 14.5, 21.5, 1.0, O, N); - p.rect(16.5, 15.5, 21.5, 21.5, 1.0, G, N); + p.rect((2.5, 15.5, 7.5, 21.5), 1.0, B, N); + p.rect((9.5, 15.5, 14.5, 21.5), 1.0, O, N); + p.rect((16.5, 15.5, 21.5, 21.5), 1.0, G, N); } // ----- Drawing ----- I::Shapes => { - p.rect(11.0, 2.5, 21.5, 13.0, 1.0, t(G), G); + p.rect((11.0, 2.5, 21.5, 13.0), 1.0, t(G), G); p.rshape(&[(15.25, 11.0), (21.5, 21.5), (9.0, 21.5)], 0.8, t(O), O); p.circle((8.0, 9.0), 5.5, t(B), B); } I::Arrange => { - p.rect(2.5, 2.5, 11.5, 11.5, 1.0, N, K); - p.rect(7.5, 7.5, 16.5, 16.5, 1.0, t(B), B); - p.rect(12.5, 12.5, 21.5, 21.5, 1.0, B, B); + p.rect((2.5, 2.5, 11.5, 11.5), 1.0, N, K); + p.rect((7.5, 7.5, 16.5, 16.5), 1.0, t(B), B); + p.rect((12.5, 12.5, 21.5, 21.5), 1.0, B, B); } I::QuickStyles => { - p.rect(3.0, 3.0, 11.0, 11.0, 1.5, B, N); - p.rect(13.0, 3.0, 21.0, 11.0, 1.5, N, O); - p.rect(3.0, 13.0, 11.0, 21.0, 1.5, t(G), G); - p.rect(13.0, 13.0, 21.0, 21.0, 1.5, PU, N); + p.rect((3.0, 3.0, 11.0, 11.0), 1.5, B, N); + p.rect((13.0, 3.0, 21.0, 11.0), 1.5, N, O); + p.rect((3.0, 13.0, 11.0, 21.0), 1.5, t(G), G); + p.rect((13.0, 13.0, 21.0, 21.0), 1.5, PU, N); } I::ShapeFill => { let m = |pts: &[P2]| tf((10.0, 10.0), 45.0, pts); @@ -241,26 +248,26 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.line(&quad_pts((5.2, 6.4), (5.5, 1.0), (10.0, 3.0), 10), K); p.poly(&[(18.5, 8.5), (20.6, 12.6), (16.4, 12.6)], B, N); p.dot((18.5, 13.4), 2.1, B); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, B, N); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, B, N); } I::ShapeOutline => { - p.tool((4.5, 15.5), -45.0, 17.0, 2.4, 4.2, N, K, 0.0, N); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, O, N); + p.tool(((4.5, 15.5), -45.0), (17.0, 2.4, 4.2), N, K, (0.0, N)); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, O, N); } I::ShapeEffects => { - p.rect(6.0, 8.0, 18.5, 20.5, 2.0, K.a(0.25), N); - p.rect(3.0, 5.0, 15.5, 17.5, 2.0, B, N); + p.rect((6.0, 8.0, 18.5, 20.5), 2.0, K.a(0.25), N); + p.rect((3.0, 5.0, 15.5, 17.5), 2.0, B, N); p.sparkle((19.0, 5.0), 4.0, Y); } I::TextFill => { p.letter_a(4.5, 2.5, 19.5, 17.0, B, 1.5); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, B, N); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, B, N); } I::TextOutline => { let outer = [(3.5, 17.0), (9.6, 2.5), (14.4, 2.5), (20.5, 17.0), (16.6, 17.0), (15.2, 13.2), (8.8, 13.2), (7.4, 17.0)]; p.shape_w(&outer, N, K, 0.8); p.poly_w(&[(10.0, 10.0), (12.0, 5.0), (14.0, 10.0)], N, K, 0.8); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, O, N); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, O, N); } I::TextEffects => { p.letter_a(3.0, 5.0, 17.0, 21.0, PU, 1.3); @@ -275,7 +282,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { pts.extend(cubic_pts(c, (15.0, 22.0), (9.0, 17.0), a, 16)); p.shape(&pts, t(B), K); for q in [a, b, c] { - p.rect(q.0 - 1.6, q.1 - 1.6, q.0 + 1.6, q.1 + 1.6, 0.3, B, N); + p.rect((q.0 - 1.6, q.1 - 1.6, q.0 + 1.6, q.1 + 1.6), 0.3, B, N); } } I::MergeShapes => { @@ -286,14 +293,22 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.circle((15.0, 12.0), 6.5, N, K); } + _ => return false, + } + true +} + +fn draw_insert_to_design(p: &Pen<'_>, icon: Icon) -> bool { + use Icon as I; + match icon { // ----- Insert ----- - I::Table => p.table(2.5, 4.0, 21.5, 20.0, 3, 3, B), + I::Table => p.table((2.5, 4.0, 21.5, 20.0), 3, 3, B), I::Picture => p.picture(2.0, 4.0, 22.0, 20.0), I::Screenshot => { for (cx, cy, sx, sy) in [(2.5, 3.5, 1.0, 1.0), (21.5, 3.5, -1.0, 1.0), (2.5, 20.5, 1.0, -1.0), (21.5, 20.5, -1.0, -1.0)] { p.rline(&[(cx, cy + 4.0 * sy), (cx, cy), (cx + 4.0 * sx, cy)], 1.0, K); } - p.rect(6.5, 7.5, 17.5, 16.5, 1.0, t(B), B); + p.rect((6.5, 7.5, 17.5, 16.5), 1.0, t(B), B); p.seg((6.5, 10.0), (17.5, 10.0), B); } I::Icons => { @@ -320,11 +335,11 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg(mid, bot, K); } I::SmartArt => { - p.rect(8.5, 2.5, 15.5, 8.5, 1.0, G, N); + p.rect((8.5, 2.5, 15.5, 8.5), 1.0, G, N); p.seg((12.0, 8.5), (12.0, 11.5), K); p.rline(&[(5.5, 14.5), (5.5, 11.5), (18.5, 11.5), (18.5, 14.5)], 1.0, K); - p.rect(2.0, 14.5, 9.0, 20.5, 1.0, B, N); - p.rect(15.0, 14.5, 22.0, 20.5, 1.0, O, N); + p.rect((2.0, 14.5, 9.0, 20.5), 1.0, B, N); + p.rect((15.0, 14.5, 22.0, 20.5), 1.0, O, N); } I::Chart => p.bars(2.5, 4.0, 21.5, 20.5), I::Zoom => { @@ -341,7 +356,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { link((15.5, 8.5), B); } I::Action => { - p.rect(2.5, 3.5, 18.0, 12.5, 2.0, t(B), B); + p.rect((2.5, 3.5, 18.0, 12.5), 2.0, t(B), B); p.cursor(11.5, 8.5, 0.82); } I::Comment => { @@ -350,17 +365,17 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg((6.5, 12.0), (14.0, 12.0), K); } I::TextBox => { - p.rect(3.5, 4.5, 20.5, 19.5, 0.5, N, K); + p.rect((3.5, 4.5, 20.5, 19.5), 0.5, N, K); for (x, y) in [(3.5, 4.5), (20.5, 4.5), (3.5, 19.5), (20.5, 19.5)] { - p.rect(x - 1.5, y - 1.5, x + 1.5, y + 1.5, 0.3, B, N); + p.rect((x - 1.5, y - 1.5, x + 1.5, y + 1.5), 0.3, B, N); } p.seg((7.5, 9.5), (16.5, 9.5), K); p.seg((7.5, 14.5), (13.5, 14.5), K); } I::HeaderFooter => { - p.rect(4.0, 2.5, 20.0, 21.5, 1.2, N, K); - p.rect(6.5, 5.0, 17.5, 7.5, 0.6, B, N); - p.rect(6.5, 16.5, 17.5, 19.0, 0.6, B, N); + p.rect((4.0, 2.5, 20.0, 21.5), 1.2, N, K); + p.rect((6.5, 5.0, 17.5, 7.5), 0.6, B, N); + p.rect((6.5, 16.5, 17.5, 19.0), 0.6, B, N); p.seg_w((7.0, 10.5), (17.0, 10.5), K.a(0.45), 0.8); p.seg_w((7.0, 13.2), (14.5, 13.2), K.a(0.45), 0.8); } @@ -370,9 +385,9 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.text_rot("A", (11.3, 11.5), 22.0, O, -10.0); } I::DateTime => { - p.rect(3.0, 5.0, 21.0, 21.0, 1.6, N, K); - p.rect(3.0, 5.0, 21.0, 9.5, 1.6, R, N); - p.rect(3.0, 5.0, 21.0, 21.0, 1.6, N, K); + p.rect((3.0, 5.0, 21.0, 21.0), 1.6, N, K); + p.rect((3.0, 5.0, 21.0, 9.5), 1.6, R, N); + p.rect((3.0, 5.0, 21.0, 21.0), 1.6, N, K); p.seg((8.0, 2.8), (8.0, 6.5), K); p.seg((16.0, 2.8), (16.0, 6.5), K); for (i, (x, y)) in [(8.0, 13.5), (12.0, 13.5), (16.0, 13.5), (8.0, 17.5), (12.0, 17.5), (16.0, 17.5)].into_iter().enumerate() { @@ -388,15 +403,15 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Object => { p.page(3.0, 2.0, 16.0, 19.0, N, K); - p.rect(11.5, 11.5, 21.5, 21.5, 1.6, B, N); - p.rect(14.2, 14.2, 18.8, 18.8, 0.6, N, W); + p.rect((11.5, 11.5, 21.5, 21.5), 1.6, B, N); + p.rect((14.2, 14.2, 18.8, 18.8), 0.6, N, W); } I::Equation => { p.line_w(&[(18.5, 6.5), (18.0, 4.0), (6.0, 4.0), (12.5, 12.0), (6.0, 20.0), (18.0, 20.0), (18.5, 17.5)], B, 1.15); } I::Symbol => p.text("ฮฉ", (12.0, 12.0), 22.0, K), I::Video => { - p.rect(2.0, 6.0, 15.5, 18.0, 2.0, t(B), K); + p.rect((2.0, 6.0, 15.5, 18.0), 2.0, t(B), K); p.rshape(&[(16.5, 10.5), (22.0, 7.0), (22.0, 17.0), (16.5, 13.5)], 0.6, N, K); p.dot((6.0, 10.0), 1.2, R); } @@ -418,16 +433,16 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { // ----- Draw tab ----- I::Pen => { - p.tool((3.5, 20.5), -45.0, 23.0, 2.2, 5.0, B, K, 3.5, K); + p.tool(((3.5, 20.5), -45.0), (23.0, 2.2, 5.0), B, K, (3.5, K)); } I::Pencil => { - p.tool((3.5, 20.5), -45.0, 22.0, 2.6, 5.0, Y, K, 3.5, R); + p.tool(((3.5, 20.5), -45.0), (22.0, 2.6, 5.0), Y, K, (3.5, R)); } I::HighlighterPen => { let m = |pts: &[P2]| tf((6.0, 15.0), -45.0, pts); p.poly(&m(&[(0.0, -1.0), (3.0, -3.2), (3.0, 3.2), (0.0, 2.2)]), Y, K); p.poly(&m(&[(3.0, -3.6), (14.5, -3.6), (14.5, 3.6), (3.0, 3.6)]), t(Y), K); - p.rect(2.0, 19.5, 13.0, 22.5, 0.8, Y, N); + p.rect((2.0, 19.5, 13.0, 22.5), 0.8, Y, N); } I::Eraser => { let m = |pts: &[P2]| tf((12.5, 11.0), -45.0, pts); @@ -465,21 +480,21 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { // ----- Design ----- I::Themes => { - p.rect(9.5, 2.5, 21.5, 14.5, 2.0, O, N); - p.rect(2.5, 8.5, 15.0, 21.5, 2.0, B, N); + p.rect((9.5, 2.5, 21.5, 14.5), 2.0, O, N); + p.rect((2.5, 8.5, 15.0, 21.5), 2.0, B, N); p.text("Aa", (8.75, 15.0), 10.0, W); } I::Variants => { - p.rect(8.0, 2.5, 21.5, 11.0, 1.2, t(PU), PU); - p.rect(5.0, 7.0, 18.5, 15.5, 1.2, t(G), G); - p.rect(2.5, 12.0, 16.0, 21.0, 1.2, B, B); + p.rect((8.0, 2.5, 21.5, 11.0), 1.2, t(PU), PU); + p.rect((5.0, 7.0, 18.5, 15.5), 1.2, t(G), G); + p.rect((2.5, 12.0, 16.0, 21.0), 1.2, B, B); } I::Colors => { for (i, c) in [R, Y, G, B].into_iter().enumerate() { let x = 3.0 + i as f32 * 4.5; - p.rect(x, 4.0, x + 4.5, 20.0, 0.0, c, N); + p.rect((x, 4.0, x + 4.5, 20.0), 0.0, c, N); } - p.rect(3.0, 4.0, 21.0, 20.0, 0.0, N, K); + p.rect((3.0, 4.0, 21.0, 20.0), 0.0, N, K); } I::Fonts => { p.text("A", (8.0, 11.0), 20.0, K); @@ -496,7 +511,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::FormatBackground => { p.rline(&[(10.0, 17.0), (2.0, 17.0), (2.0, 4.0), (20.0, 4.0), (20.0, 9.0)], 1.5, K); - p.rect(4.5, 6.5, 13.5, 12.5, 0.6, t(PU), N); + p.rect((4.5, 6.5, 13.5, 12.5), 0.6, t(PU), N); p.brush((12.0, 20.5), -45.0, 13.0, Y); } I::DesignIdeas => { @@ -511,13 +526,21 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } } + _ => return false, + } + true +} + +fn draw_motion_to_view(p: &Pen<'_>, icon: Icon) -> bool { + use Icon as I; + match icon { // ----- Transitions / animations ----- I::Preview => { p.circle((12.0, 12.0), 9.5, N, K); p.rshape(&[(9.5, 7.0), (17.0, 12.0), (9.5, 17.0)], 0.8, G, N); } I::EffectOptions => { - p.rect(2.5, 8.5, 15.5, 21.5, 1.5, t(B), K); + p.rect((2.5, 8.5, 15.5, 21.5), 1.5, t(B), K); p.arrow((9.0, 15.0), (21.5, 2.5), B); } I::Sound => { @@ -533,14 +556,14 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Duration => { p.circle((12.0, 13.5), 8.0, N, K); - p.rect(10.0, 2.0, 14.0, 4.0, 0.6, K, N); + p.rect((10.0, 2.0, 14.0, 4.0), 0.6, K, N); p.seg((12.0, 4.0), (12.0, 5.5), K); p.seg((12.0, 13.5), (15.8, 9.0), B); p.dot((12.0, 13.5), 1.3, B); } I::ApplyToAll => { p.rline(&[(6.0, 7.5), (6.0, 3.0), (21.5, 3.0), (21.5, 13.5), (17.5, 13.5)], 1.5, K); - p.rect(2.5, 7.5, 17.5, 18.0, 1.5, N, K); + p.rect((2.5, 7.5, 17.5, 18.0), 1.5, N, K); p.badge((18.0, 18.0), 4.8, G, Glyph::Check); } I::AnimEntrance => { @@ -563,9 +586,9 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.circle((4.5, 19.5), 2.4, G, N); } I::AnimationPane => { - p.rect(2.5, 3.0, 21.5, 21.0, 1.5, N, K); + p.rect((2.5, 3.0, 21.5, 21.0), 1.5, N, K); p.seg((11.0, 3.0), (11.0, 21.0), K); - p.rect(4.5, 7.0, 8.5, 12.0, 0.5, t(B), N); + p.rect((4.5, 7.0, 8.5, 12.0), 0.5, t(B), N); for (y, c) in [(7.5, G), (12.0, Y), (16.5, R)] { p.seg_w((13.5, y), (19.0, y), c, 1.4); } @@ -585,7 +608,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { // ----- Slide show ----- I::PlayFromStart => { p.frame(2.0, 4.0, 22.0, 20.0); - p.rect(7.0, 8.0, 9.2, 16.0, 0.5, G, N); + p.rect((7.0, 8.0, 9.2, 16.0), 0.5, G, N); p.rshape(&[(11.0, 8.0), (17.5, 12.0), (11.0, 16.0)], 0.6, G, N); } I::PlayFromCurrent => { @@ -594,15 +617,15 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::PresenterView => { p.monitor(2.0, 3.0, 22.0, 16.5); - p.rect(4.5, 5.5, 13.0, 12.0, 0.6, t(B), B); + p.rect((4.5, 5.5, 13.0, 12.0), 0.6, t(B), B); p.seg_w((15.0, 6.5), (19.5, 6.5), K, 0.8); p.seg_w((15.0, 9.5), (19.5, 9.5), K, 0.8); p.seg_w((15.0, 12.5), (18.0, 12.5), K, 0.8); } I::CustomShow => { - p.rect(2.5, 2.5, 12.5, 8.0, 1.0, N, K); - p.rect(2.5, 9.5, 12.5, 15.0, 1.0, O, O); - p.rect(2.5, 16.5, 12.5, 22.0, 1.0, N, K); + p.rect((2.5, 2.5, 12.5, 8.0), 1.0, N, K); + p.rect((2.5, 9.5, 12.5, 15.0), 1.0, O, O); + p.rect((2.5, 16.5, 12.5, 22.0), 1.0, N, K); p.rshape(&[(15.5, 8.0), (21.5, 12.25), (15.5, 16.5)], 0.6, G, N); } I::SetUpShow => { @@ -620,7 +643,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.circle((12.0, 12.0), 5.5, R, N); } I::Subtitles => { - p.rect(2.0, 4.5, 22.0, 19.5, 2.0, N, K); + p.rect((2.0, 4.5, 22.0, 19.5), 2.0, N, K); p.seg((5.5, 12.0), (11.0, 12.0), B); p.seg((13.5, 12.0), (18.5, 12.0), B); p.seg((5.5, 16.0), (8.5, 16.0), B); @@ -639,8 +662,8 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.line_w(&[(6.0, 16.0), (10.0, 20.0), (19.5, 11.0)], G, 1.3); } I::Thesaurus => { - p.rect(8.0, 2.5, 19.5, 21.5, 1.2, t(B), N); - p.rect(4.5, 2.5, 19.5, 21.5, 1.5, N, K); + p.rect((8.0, 2.5, 19.5, 21.5), 1.2, t(B), N); + p.rect((4.5, 2.5, 19.5, 21.5), 1.5, N, K); p.seg((8.0, 2.5), (8.0, 21.5), K); p.poly(&[(13.5, 2.5), (17.0, 2.5), (17.0, 9.0), (15.25, 7.5), (13.5, 9.0)], R, N); } @@ -652,8 +675,8 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.line(&[(9.0, 18.5), (12.0, 13.8), (15.0, 18.5)], B); } I::Translate => { - p.rect(10.0, 9.0, 21.5, 20.5, 2.0, O, N); - p.rect(2.5, 3.5, 14.0, 15.0, 2.0, B, N); + p.rect((10.0, 9.0, 21.5, 20.5), 2.0, O, N); + p.rect((2.5, 3.5, 14.0, 15.0), 2.0, B, N); p.letter_a(5.5, 5.8, 11.0, 12.8, W, 0.9); p.seg_w((17.75, 10.8), (17.75, 11.9), W, 0.8); p.seg_w((15.2, 12.9), (20.3, 12.9), W, 0.8); @@ -690,8 +713,8 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg((5.5, 14.2), (11.0, 14.2), K); } I::Compare => { - p.rect(2.5, 2.5, 11.0, 15.5, 1.2, N, K); - p.rect(13.0, 8.5, 21.5, 21.5, 1.2, t(B), B); + p.rect((2.5, 2.5, 11.0, 15.5), 1.2, N, K); + p.rect((13.0, 8.5, 21.5, 21.5), 1.2, t(B), B); p.arrow((13.0, 4.0), (21.5, 4.0), K); p.arrow((11.0, 20.0), (2.5, 20.0), K); } @@ -702,37 +725,37 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { // ----- View ----- I::ViewNormal => { - p.rect(2.0, 3.5, 22.0, 20.5, 1.5, N, K); + p.rect((2.0, 3.5, 22.0, 20.5), 1.5, N, K); p.seg((8.0, 3.5), (8.0, 20.5), K); for y in [6.0, 10.5, 15.0] { - p.rect(3.9, y, 6.2, y + 2.6, 0.3, K.a(0.55), N); + p.rect((3.9, y, 6.2, y + 2.6), 0.3, K.a(0.55), N); } - p.rect(10.0, 6.0, 20.0, 15.0, 0.6, t(B), B); + p.rect((10.0, 6.0, 20.0, 15.0), 0.6, t(B), B); p.seg_w((10.0, 18.0), (17.0, 18.0), K.a(0.5), 0.8); } I::ViewOutline => { - p.rect(2.0, 3.5, 22.0, 20.5, 1.5, N, K); + p.rect((2.0, 3.5, 22.0, 20.5), 1.5, N, K); p.seg((11.0, 3.5), (11.0, 20.5), K); p.seg_w((4.0, 7.0), (9.0, 7.0), K, 0.8); p.seg_w((5.5, 10.0), (9.0, 10.0), K, 0.8); p.seg_w((5.5, 13.0), (9.0, 13.0), K, 0.8); p.seg_w((4.0, 16.0), (9.0, 16.0), K, 0.8); - p.rect(13.0, 7.0, 20.0, 13.0, 0.6, t(B), B); + p.rect((13.0, 7.0, 20.0, 13.0), 0.6, t(B), B); } I::ViewSorter => { for (i, (x0, x1)) in [(2.5, 11.0), (13.0, 21.5)].into_iter().enumerate() { for (j, (y0, y1)) in [(4.0, 10.5), (13.5, 20.0)].into_iter().enumerate() { if i == 0 && j == 0 { - p.rect(x0, y0, x1, y1, 0.8, t(B), B); + p.rect((x0, y0, x1, y1), 0.8, t(B), B); } else { - p.rect(x0, y0, x1, y1, 0.8, N, K); + p.rect((x0, y0, x1, y1), 0.8, N, K); } } } } I::ViewNotesPage => { - p.rect(5.0, 2.0, 19.0, 22.0, 1.2, N, K); - p.rect(7.5, 4.5, 16.5, 10.5, 0.6, t(B), B); + p.rect((5.0, 2.0, 19.0, 22.0), 1.2, N, K); + p.rect((7.5, 4.5, 16.5, 10.5), 0.6, t(B), B); p.seg_w((7.5, 14.0), (16.5, 14.0), K, 0.8); p.seg_w((7.5, 17.5), (14.0, 17.5), K, 0.8); } @@ -741,24 +764,24 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg((12.0, 7.0), (12.0, 20.5), K); } I::SlideMaster => { - p.rect(2.0, 2.5, 15.0, 10.5, 1.2, t(PU), PU); + p.rect((2.0, 2.5, 15.0, 10.5), 1.2, t(PU), PU); p.seg((4.5, 5.5), (11.0, 5.5), PU); p.rline(&[(5.0, 10.5), (5.0, 19.5), (9.0, 19.5)], 1.0, K); p.seg((5.0, 14.0), (9.0, 14.0), K); - p.rect(9.0, 12.0, 17.0, 16.0, 0.8, N, K); - p.rect(9.0, 17.5, 17.0, 21.5, 0.8, N, K); + p.rect((9.0, 12.0, 17.0, 16.0), 0.8, N, K); + p.rect((9.0, 17.5, 17.0, 21.5), 0.8, N, K); } I::HandoutMaster => { - p.rect(4.0, 2.0, 20.0, 22.0, 1.2, N, K); + p.rect((4.0, 2.0, 20.0, 22.0), 1.2, N, K); for (x, y) in [(6.5, 4.5), (12.75, 4.5), (6.5, 11.0), (12.75, 11.0)] { - p.rect(x, y, x + 4.75, y + 4.5, 0.4, t(PU), PU); + p.rect((x, y, x + 4.75, y + 4.5), 0.4, t(PU), PU); } p.seg_w((6.5, 19.0), (17.5, 19.0), K, 0.8); } I::NotesMaster => { p.rline(&[(8.0, 4.0), (8.0, 2.0), (21.0, 2.0), (21.0, 19.0), (16.0, 19.0)], 1.2, PU); - p.rect(3.0, 4.5, 16.0, 22.0, 1.2, N, K); - p.rect(5.5, 7.0, 13.5, 12.0, 0.5, t(PU), PU); + p.rect((3.0, 4.5, 16.0, 22.0), 1.2, N, K); + p.rect((5.5, 7.0, 13.5, 12.0), 0.5, t(PU), PU); p.seg_w((5.5, 15.5), (13.5, 15.5), K, 0.8); p.seg_w((5.5, 18.8), (11.5, 18.8), K, 0.8); } @@ -767,15 +790,15 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg_w((v, 3.0), (v, 21.0), K.a(0.6), 0.7); p.seg_w((3.0, v), (21.0, v), K.a(0.6), 0.7); } - p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + p.rect((3.0, 3.0, 21.0, 21.0), 1.2, N, K); } I::Guides => { - p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + p.rect((3.0, 3.0, 21.0, 21.0), 1.2, N, K); p.dashed(&[(12.0, 1.5), (12.0, 22.5)], B, 2.2, 1.6); p.dashed(&[(1.5, 12.0), (22.5, 12.0)], B, 2.2, 1.6); } I::Notes => { - p.rect(4.0, 4.0, 20.0, 21.5, 1.5, t(Y), K); + p.rect((4.0, 4.0, 20.0, 21.5), 1.5, t(Y), K); for x in [8.0, 12.0, 16.0] { p.seg((x, 2.3), (x, 5.8), K); } @@ -788,16 +811,16 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { for (cx, cy, sx, sy) in [(2.5, 2.5, 1.0, 1.0), (21.5, 2.5, -1.0, 1.0), (2.5, 21.5, 1.0, -1.0), (21.5, 21.5, -1.0, -1.0)] { p.rline(&[(cx, cy + 5.0 * sy), (cx, cy), (cx + 5.0 * sx, cy)], 0.8, K); } - p.rect(7.0, 8.0, 17.0, 16.0, 1.0, t(B), B); + p.rect((7.0, 8.0, 17.0, 16.0), 1.0, t(B), B); } I::Grayscale => { - p.rect(3.0, 3.0, 9.0, 21.0, 0.0, K.a(0.85), N); - p.rect(9.0, 3.0, 15.0, 21.0, 0.0, K.a(0.45), N); - p.rect(15.0, 3.0, 21.0, 21.0, 0.0, K.a(0.15), N); - p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + p.rect((3.0, 3.0, 9.0, 21.0), 0.0, K.a(0.85), N); + p.rect((9.0, 3.0, 15.0, 21.0), 0.0, K.a(0.45), N); + p.rect((15.0, 3.0, 21.0, 21.0), 0.0, K.a(0.15), N); + p.rect((3.0, 3.0, 21.0, 21.0), 1.2, N, K); } I::NewWindow => { - p.rect(2.0, 3.5, 20.0, 17.0, 1.5, N, K); + p.rect((2.0, 3.5, 20.0, 17.0), 1.5, N, K); p.seg((2.0, 7.0), (20.0, 7.0), K); p.dot((4.5, 5.25), 0.6, K); p.badge((18.5, 18.0), 4.8, G, Glyph::Plus); @@ -809,6 +832,14 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.gear_badge((17.5, 17.5), 5.2, O); } + _ => return false, + } + true +} + +fn draw_arrange_to_media(p: &Pen<'_>, icon: Icon) -> bool { + use Icon as I; + match icon { // ----- Arrange ----- I::BringForward => { p.layer(10.0, 15.5, 8.0, 4.0, N, K); @@ -836,64 +867,64 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Group => { p.dashed(&[(2.5, 2.5), (21.5, 2.5), (21.5, 21.5), (2.5, 21.5), (2.5, 2.5)], B, 2.0, 1.6); - p.rect(5.5, 5.5, 13.0, 13.0, 0.8, t(G), G); + p.rect((5.5, 5.5, 13.0, 13.0), 0.8, t(G), G); p.circle((15.5, 15.5), 4.3, t(O), O); } I::Ungroup => { p.dashed_w(&[(2.0, 2.0), (12.0, 2.0), (12.0, 12.0), (2.0, 12.0), (2.0, 2.0)], B, 1.6, 1.3, 0.8); - p.rect(4.5, 4.5, 9.5, 9.5, 0.6, t(G), G); + p.rect((4.5, 4.5, 9.5, 9.5), 0.6, t(G), G); p.dashed_w(&[(12.0, 12.0), (22.0, 12.0), (22.0, 22.0), (12.0, 22.0), (12.0, 12.0)], B, 1.6, 1.3, 0.8); p.circle((17.0, 17.0), 2.9, t(O), O); } I::AlignObjects => { p.dashed(&[(2.0, 12.0), (22.0, 12.0)], K, 2.0, 1.5); - p.rect(3.5, 4.0, 8.0, 20.0, 0.8, B, N); - p.rect(10.0, 7.5, 14.0, 16.5, 0.8, O, N); - p.rect(16.0, 5.5, 20.5, 18.5, 0.8, G, N); + p.rect((3.5, 4.0, 8.0, 20.0), 0.8, B, N); + p.rect((10.0, 7.5, 14.0, 16.5), 0.8, O, N); + p.rect((16.0, 5.5, 20.5, 18.5), 0.8, G, N); } I::AlignObjLeft => { - p.rect(3.5, 5.0, 19.5, 10.5, 0.8, t(B), B); - p.rect(3.5, 13.5, 13.5, 19.0, 0.8, t(B), B); + p.rect((3.5, 5.0, 19.5, 10.5), 0.8, t(B), B); + p.rect((3.5, 13.5, 13.5, 19.0), 0.8, t(B), B); p.seg((3.5, 2.5), (3.5, 21.5), K); } I::AlignObjCenter => { - p.rect(4.0, 5.0, 20.0, 10.5, 0.8, t(B), B); - p.rect(7.0, 13.5, 17.0, 19.0, 0.8, t(B), B); + p.rect((4.0, 5.0, 20.0, 10.5), 0.8, t(B), B); + p.rect((7.0, 13.5, 17.0, 19.0), 0.8, t(B), B); p.seg((12.0, 2.5), (12.0, 21.5), K); } I::AlignObjRight => { - p.rect(4.5, 5.0, 20.5, 10.5, 0.8, t(B), B); - p.rect(10.5, 13.5, 20.5, 19.0, 0.8, t(B), B); + p.rect((4.5, 5.0, 20.5, 10.5), 0.8, t(B), B); + p.rect((10.5, 13.5, 20.5, 19.0), 0.8, t(B), B); p.seg((20.5, 2.5), (20.5, 21.5), K); } I::AlignObjTop => { - p.rect(5.0, 3.5, 10.5, 19.5, 0.8, t(B), B); - p.rect(13.5, 3.5, 19.0, 13.5, 0.8, t(B), B); + p.rect((5.0, 3.5, 10.5, 19.5), 0.8, t(B), B); + p.rect((13.5, 3.5, 19.0, 13.5), 0.8, t(B), B); p.seg((2.5, 3.5), (21.5, 3.5), K); } I::AlignObjMiddle => { - p.rect(5.0, 4.0, 10.5, 20.0, 0.8, t(B), B); - p.rect(13.5, 7.0, 19.0, 17.0, 0.8, t(B), B); + p.rect((5.0, 4.0, 10.5, 20.0), 0.8, t(B), B); + p.rect((13.5, 7.0, 19.0, 17.0), 0.8, t(B), B); p.seg((2.5, 12.0), (21.5, 12.0), K); } I::AlignObjBottom => { - p.rect(5.0, 4.5, 10.5, 20.5, 0.8, t(B), B); - p.rect(13.5, 10.5, 19.0, 20.5, 0.8, t(B), B); + p.rect((5.0, 4.5, 10.5, 20.5), 0.8, t(B), B); + p.rect((13.5, 10.5, 19.0, 20.5), 0.8, t(B), B); p.seg((2.5, 20.5), (21.5, 20.5), K); } I::DistributeH => { p.seg((2.5, 3.0), (2.5, 21.0), K); p.seg((21.5, 3.0), (21.5, 21.0), K); - p.rect(4.9, 7.0, 8.2, 17.0, 0.6, t(B), B); - p.rect(10.35, 5.0, 13.65, 19.0, 0.6, t(B), B); - p.rect(15.8, 8.5, 19.1, 15.5, 0.6, t(B), B); + p.rect((4.9, 7.0, 8.2, 17.0), 0.6, t(B), B); + p.rect((10.35, 5.0, 13.65, 19.0), 0.6, t(B), B); + p.rect((15.8, 8.5, 19.1, 15.5), 0.6, t(B), B); } I::DistributeV => { p.seg((3.0, 2.5), (21.0, 2.5), K); p.seg((3.0, 21.5), (21.0, 21.5), K); - p.rect(7.0, 4.9, 17.0, 8.2, 0.6, t(B), B); - p.rect(5.0, 10.35, 19.0, 13.65, 0.6, t(B), B); - p.rect(8.5, 15.8, 15.5, 19.1, 0.6, t(B), B); + p.rect((7.0, 4.9, 17.0, 8.2), 0.6, t(B), B); + p.rect((5.0, 10.35, 19.0, 13.65), 0.6, t(B), B); + p.rect((8.5, 15.8, 15.5, 19.1), 0.6, t(B), B); } I::RotateLeft => { p.poly(&[(3.0, 21.5), (3.0, 13.0), (12.0, 21.5)], t(B), B); @@ -914,15 +945,15 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.poly(&[(5.0, 14.5), (19.0, 21.5), (19.0, 14.5)], B, B); } I::SelectionPane => { - p.rect(3.0, 2.5, 21.0, 21.5, 1.5, N, K); - p.rect(4.6, 4.6, 19.4, 9.4, 0.8, t(B), N); + p.rect((3.0, 2.5, 21.0, 21.5), 1.5, N, K); + p.rect((4.6, 4.6, 19.4, 9.4), 0.8, t(B), N); for (y, c) in [(7.0, B), (12.0, O), (17.0, G)] { - p.rect(6.0, y - 1.3, 8.6, y + 1.3, 0.4, c, N); + p.rect((6.0, y - 1.3, 8.6, y + 1.3), 0.4, c, N); p.seg_w((10.5, y), (17.5, y), K, 0.85); } } I::Rotate => { - p.rect(9.0, 9.0, 15.0, 15.0, 0.8, t(B), B); + p.rect((9.0, 9.0, 15.0, 15.0), 0.8, t(B), B); p.arc_arrow((12.0, 12.0), 8.5, -70.0, 220.0, K); } @@ -945,7 +976,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.circle((15.7, 14.6), 5.6, B.a(0.6), N); } I::ArtisticEffects => { - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K); + p.rect((2.0, 4.0, 22.0, 20.0), 1.5, N, K); p.curve_w((5.5, 16.0), (8.0, 4.5), (14.0, 21.0), (18.5, 8.0), PU, 2.0); } I::Transparency => { @@ -954,12 +985,12 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { for j in 0..4 { if (i + j) % 2 == 0 { let (x, y) = (3.0 + i as f32 * cs, 3.0 + j as f32 * cs); - p.rect(x, y, x + cs, y + cs, 0.0, K.a(0.3), N); + p.rect((x, y, x + cs, y + cs), 0.0, K.a(0.3), N); } } } - p.rect(3.0, 3.0, 16.0, 16.0, 0.6, N, K.a(0.6)); - p.rect(9.0, 9.0, 21.0, 21.0, 1.2, B.a(0.55), B); + p.rect((3.0, 3.0, 16.0, 16.0), 0.6, N, K.a(0.6)); + p.rect((9.0, 9.0, 21.0, 21.0), 1.2, B.a(0.55), B); } I::CompressPictures => { p.picture(6.5, 6.0, 17.5, 18.0); @@ -983,10 +1014,10 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.arc_arrow((17.0, 17.5), 4.0, -10.0, -260.0, B); } I::RemoveBackground => { - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, PU.a(0.55), N); + p.rect((2.0, 4.0, 22.0, 20.0), 1.5, PU.a(0.55), N); p.dot((12.0, 10.0), 3.0, B); p.poly(&arc_pts((12.0, 19.3), 6.0, 5.5, 180.0, 360.0), B, N); - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K); + p.rect((2.0, 4.0, 22.0, 20.0), 1.5, N, K); } I::AltText => { p.picture(2.0, 2.0, 15.5, 12.5); @@ -1002,24 +1033,24 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { I::InsertLeft => ((10.0, 3.0, 21.0, 21.0, 3, 2), (2.5, 3.0, 7.5, 21.0)), _ => ((3.0, 3.0, 14.0, 21.0, 3, 2), (16.5, 3.0, 21.5, 21.0)), }; - p.table(grid.0, grid.1, grid.2, grid.3, grid.4, grid.5, N); - p.rect(add.0, add.1, add.2, add.3, 1.0, t(G), G); + p.table((grid.0, grid.1, grid.2, grid.3), grid.4, grid.5, N); + p.rect((add.0, add.1, add.2, add.3), 1.0, t(G), G); let c = ((add.0 + add.2) * 0.5, (add.1 + add.3) * 0.5); p.seg_w((c.0 - 1.6, c.1), (c.0 + 1.6, c.1), G, 0.9); p.seg_w((c.0, c.1 - 1.6), (c.0, c.1 + 1.6), G, 0.9); } I::DeleteTable => { - p.table(2.5, 3.0, 19.5, 17.0, 3, 3, B); + p.table((2.5, 3.0, 19.5, 17.0), 3, 3, B); p.badge((18.0, 18.0), 5.0, R, Glyph::Cross); } I::MergeCells => { - p.rect(2.5, 6.0, 21.5, 18.0, 1.2, N, K); + p.rect((2.5, 6.0, 21.5, 18.0), 1.2, N, K); p.dashed(&[(12.0, 6.0), (12.0, 18.0)], K.a(0.6), 1.5, 1.2); p.arrow((4.5, 12.0), (10.2, 12.0), B); p.arrow((19.5, 12.0), (13.8, 12.0), B); } I::SplitCells => { - p.rect(2.5, 6.0, 21.5, 18.0, 1.2, N, K); + p.rect((2.5, 6.0, 21.5, 18.0), 1.2, N, K); p.seg((12.0, 6.0), (12.0, 18.0), K); p.arrow((10.0, 12.0), (4.5, 12.0), B); p.arrow((14.0, 12.0), (19.5, 12.0), B); @@ -1027,12 +1058,12 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { I::Borders => { p.dotted(&[(12.0, 5.0), (12.0, 19.0)], K, 2.2, 0.45); p.dotted(&[(5.0, 12.0), (19.0, 12.0)], K, 2.2, 0.45); - p.rect(3.0, 3.0, 21.0, 21.0, 0.6, N, B); + p.rect((3.0, 3.0, 21.0, 21.0), 0.6, N, B); } I::Shading => { - p.rect(3.0, 3.0, 12.0, 12.0, 0.0, Y.a(0.8), N); - p.rect(12.0, 12.0, 21.0, 21.0, 0.0, Y.a(0.8), N); - p.table(3.0, 3.0, 21.0, 21.0, 2, 2, N); + p.rect((3.0, 3.0, 12.0, 12.0), 0.0, Y.a(0.8), N); + p.rect((12.0, 12.0, 21.0, 21.0), 0.0, Y.a(0.8), N); + p.table((3.0, 3.0, 21.0, 21.0), 2, 2, N); } // ----- Chart ----- @@ -1045,40 +1076,40 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.brush((14.0, 10.0), -45.0, 12.0, PU); } I::SwitchRowCol => { - p.table(2.5, 2.5, 11.0, 11.0, 2, 2, B); - p.rect(13.0, 13.0, 17.25, 21.5, 0.6, O, N); - p.table(13.0, 13.0, 21.5, 21.5, 2, 2, N); + p.table((2.5, 2.5, 11.0, 11.0), 2, 2, B); + p.rect((13.0, 13.0, 17.25, 21.5), 0.6, O, N); + p.table((13.0, 13.0, 21.5, 21.5), 2, 2, N); p.line(&quad_pts((14.0, 4.0), (20.0, 4.0), (20.0, 9.5), 10), K); p.head((20.0, 11.0), 90.0, 3.6, K); p.line(&quad_pts((10.0, 20.0), (4.0, 20.0), (4.0, 14.5), 10), K); p.head((4.0, 13.0), -90.0, 3.6, K); } I::EditData => { - p.table(2.5, 3.0, 18.0, 16.0, 3, 3, B); - p.tool((11.0, 21.5), -45.0, 14.0, 2.2, 3.8, Y, K, 2.5, R); + p.table((2.5, 3.0, 18.0, 16.0), 3, 3, B); + p.tool(((11.0, 21.5), -45.0), (14.0, 2.2, 3.8), Y, K, (2.5, R)); } I::ChangeChartType => { p.circle((8.0, 8.0), 5.8, t(B), B); let mut wedge = vec![(8.0, 8.0)]; wedge.extend(arc_pts((8.0, 8.0), 5.8, 5.8, -90.0, 0.0)); p.poly(&wedge, O, N); - p.rect(13.0, 15.0, 15.8, 21.0, 0.4, G, N); - p.rect(17.5, 11.0, 20.3, 21.0, 0.4, B, N); + p.rect((13.0, 15.0, 15.8, 21.0), 0.4, G, N); + p.rect((17.5, 11.0, 20.3, 21.0), 0.4, B, N); p.seg((11.5, 21.5), (22.0, 21.5), K); } // ----- Media ----- I::Play => p.rshape(&[(6.5, 4.0), (19.5, 12.0), (6.5, 20.0)], 1.0, G, N), I::Pause => { - p.rect(6.0, 4.5, 10.0, 19.5, 1.0, K, N); - p.rect(14.0, 4.5, 18.0, 19.5, 1.0, K, N); + p.rect((6.0, 4.5, 10.0, 19.5), 1.0, K, N); + p.rect((14.0, 4.5, 18.0, 19.5), 1.0, K, N); } - I::Stop => p.rect(5.0, 5.0, 19.0, 19.0, 2.0, K, N), + I::Stop => p.rect((5.0, 5.0, 19.0, 19.0), 2.0, K, N), I::TrimMedia => { - p.rect(2.0, 8.0, 22.0, 16.0, 1.2, N, K); - p.rect(7.0, 8.0, 17.0, 16.0, 0.0, t(Y), N); - p.rect(5.8, 5.0, 8.2, 19.0, 0.8, Y, N); - p.rect(15.8, 5.0, 18.2, 19.0, 0.8, Y, N); + p.rect((2.0, 8.0, 22.0, 16.0), 1.2, N, K); + p.rect((7.0, 8.0, 17.0, 16.0), 0.0, t(Y), N); + p.rect((5.8, 5.0, 8.2, 19.0), 0.8, Y, N); + p.rect((15.8, 5.0, 18.2, 19.0), 0.8, Y, N); } I::Volume => { p.speaker(2.5, 12.0, N, K); @@ -1094,17 +1125,25 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.rshape(&[(6.0, 2.5), (18.0, 2.5), (18.0, 21.5), (12.0, 16.5), (6.0, 21.5)], 0.8, R, N); } + _ => return false, + } + true +} + +fn draw_chrome_and_status(p: &Pen<'_>, icon: Icon) -> bool { + use Icon as I; + match icon { // ----- Window chrome ----- I::Home => { - p.rect(10.0, 14.5, 14.0, 21.0, 0.8, B, N); + p.rect((10.0, 14.5, 14.0, 21.0), 0.8, B, N); p.line(&[(2.5, 11.5), (12.0, 3.0), (21.5, 11.5)], K); p.rline(&[(5.0, 9.5), (5.0, 21.0), (19.0, 21.0), (19.0, 9.5)], 1.2, K); } I::Save => { p.rshape(&[(3.0, 3.0), (17.5, 3.0), (21.0, 6.5), (21.0, 21.0), (3.0, 21.0)], 1.5, t(B), B); - p.rect(7.0, 3.0, 15.0, 8.5, 0.6, N, B); + p.rect((7.0, 3.0, 15.0, 8.5), 0.6, N, B); p.seg((12.5, 4.5), (12.5, 7.0), B); - p.rect(6.5, 13.0, 17.5, 21.0, 0.8, N, B); + p.rect((6.5, 13.0, 17.5, 21.0), 0.8, N, B); } I::Undo | I::Redo => { let mut pts = vec![(6.0, 8.5), (14.0, 8.5)]; @@ -1147,9 +1186,9 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.arrow((8.5, 12.0), (22.0, 12.0), B); } I::Print => { - p.rect(6.5, 2.5, 17.5, 9.0, 0.8, N, K); + p.rect((6.5, 2.5, 17.5, 9.0), 0.8, N, K); p.rline(&[(6.5, 17.5), (2.5, 17.5), (2.5, 9.0), (21.5, 9.0), (21.5, 17.5), (17.5, 17.5)], 1.8, K); - p.rect(6.5, 14.0, 17.5, 21.5, 0.8, N, K); + p.rect((6.5, 14.0, 17.5, 21.5), 0.8, N, K); p.dot((18.5, 12.0), 1.1, G); } I::Settings => { @@ -1180,7 +1219,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0)); sh.push((16.5, 10.5)); p.line(&sh, K); - p.rect(4.5, 10.5, 19.5, 21.5, 1.8, N, K); + p.rect((4.5, 10.5, 19.5, 21.5), 1.8, N, K); p.seg((12.0, 14.5), (12.0, 17.5), K); } I::Unlock => { @@ -1189,7 +1228,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { sh.extend(arc_pts((16.5, 6.0), 4.5, 4.5, 180.0, 360.0)); sh.push((21.0, 8.5)); p.line(&sh, K); - p.rect(3.0, 10.5, 16.5, 21.5, 1.8, N, K); + p.rect((3.0, 10.5, 16.5, 21.5), 1.8, N, K); p.seg((9.75, 14.5), (9.75, 17.5), K); } I::Eye | I::EyeOff => { @@ -1225,7 +1264,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Star => p.star((12.0, 12.8), 10.0, t(Y), Y), I::Image => { - p.rect(2.5, 4.0, 21.5, 20.0, 1.5, N, K); + p.rect((2.5, 4.0, 21.5, 20.0), 1.5, N, K); p.circle((16.0, 9.0), 1.8, N, K); p.line(&[(2.5, 17.5), (8.5, 11.0), (14.0, 16.5), (16.5, 14.0), (21.5, 18.5)], K); } @@ -1244,7 +1283,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Grid => { for (x, y) in [(3.0, 3.0), (13.0, 3.0), (3.0, 13.0), (13.0, 13.0)] { - p.rect(x, y, x + 8.0, y + 8.0, 1.5, N, K); + p.rect((x, y, x + 8.0, y + 8.0), 1.5, N, K); } } I::List => { @@ -1278,7 +1317,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::SaveAs => { p.rline(&[(11.0, 20.5), (2.5, 20.5), (2.5, 3.0), (15.0, 3.0), (18.0, 6.0), (18.0, 10.5)], 1.2, K); - p.rect(6.0, 3.0, 13.0, 7.5, 0.6, N, K); + p.rect((6.0, 3.0, 13.0, 7.5), 0.6, N, K); p.seg_w((13.5, 21.0), (21.5, 13.0), O, 1.4); p.seg_w((12.5, 22.0), (13.5, 21.0), K, 1.0); } @@ -1300,18 +1339,18 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { // ----- Status bar (monochrome) ----- I::NotesSmall => { - p.rect(3.0, 4.0, 21.0, 20.0, 1.5, N, K); + p.rect((3.0, 4.0, 21.0, 20.0), 1.5, N, K); p.seg((3.0, 13.5), (21.0, 13.5), K); p.seg((6.5, 16.8), (15.5, 16.8), K); } I::CommentsSmall => p.bubble(3.0, 4.0, 21.0, 16.5, N, K), I::ViewNormalSmall => { - p.rect(2.5, 4.0, 21.5, 20.0, 1.5, N, K); + p.rect((2.5, 4.0, 21.5, 20.0), 1.5, N, K); p.seg((8.5, 4.0), (8.5, 20.0), K); } I::ViewSorterSmall => { for (x, y) in [(2.5, 4.5), (13.0, 4.5), (2.5, 13.5), (13.0, 13.5)] { - p.rect(x, y, x + 8.5, y + 6.0, 1.0, N, K); + p.rect((x, y, x + 8.5, y + 6.0), 1.0, N, K); } } I::ViewReadingSmall => { @@ -1320,7 +1359,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::ViewShowSmall => { p.seg((2.0, 4.0), (22.0, 4.0), K); - p.rect(3.5, 4.0, 20.5, 16.0, 0.8, N, K); + p.rect((3.5, 4.0, 20.5, 16.0), 0.8, N, K); p.seg((12.0, 16.0), (12.0, 19.0), K); p.line(&[(8.5, 22.0), (12.0, 19.0), (15.5, 22.0)], K); } @@ -1328,7 +1367,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { for (cx, cy, sx, sy) in [(3.0, 3.0, 1.0, 1.0), (21.0, 3.0, -1.0, 1.0), (3.0, 21.0, 1.0, -1.0), (21.0, 21.0, -1.0, -1.0)] { p.rline(&[(cx, cy + 5.0 * sy), (cx, cy), (cx + 5.0 * sx, cy)], 0.8, K); } - p.rect(8.0, 8.5, 16.0, 15.5, 0.8, N, K); + p.rect((8.0, 8.5, 16.0, 15.5), 0.8, N, K); } I::ZoomIn | I::ZoomOut => { p.magnifier((10.0, 10.0), 7.0, N, K); @@ -1337,5 +1376,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg((10.0, 7.0), (10.0, 13.0), K); } } + _ => return false, } + true } diff --git a/crates/ui-egui/src/icons/geometry.rs b/crates/ui-egui/src/icons/geometry.rs index 93ac96c0..8c020a9e 100644 --- a/crates/ui-egui/src/icons/geometry.rs +++ b/crates/ui-egui/src/icons/geometry.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, BLUE, Color32, DARK, FontId, GOLD, GREEN, Mesh, ORANGE, PI, PURPLE, Painter, PathShape, Pos2, RED, Rect, Shape, Stroke, StrokeKind, + TextShape, pos2, vec2, +}; // --------------------------------------------------------------------------------------------- // Drawing toolkit @@ -49,6 +52,8 @@ pub(super) fn t(c: C) -> C { } pub(super) type P2 = (f32, f32); +/// A box as `(x0, y0, x1, y1)`. +pub(super) type Box4 = (f32, f32, f32, f32); #[derive(Clone, Copy)] pub(super) enum Glyph { @@ -363,13 +368,13 @@ impl Pen<'_> { self.p.add(PathShape::convex_polygon(v, self.col(fill), self.stroke(stroke, w))); } - fn rect_w(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32, fill: C, stroke: C, w: f32) { + fn rect_w(&self, (x0, y0, x1, y1): Box4, r: f32, fill: C, stroke: C, w: f32) { let rect = Rect::from_min_max(self.pt((x0, y0)), self.pt((x1, y1))); self.p.rect(rect, r * self.k, self.col(fill), self.stroke(stroke, w), StrokeKind::Middle); } - pub(super) fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32, fill: C, stroke: C) { - self.rect_w(x0, y0, x1, y1, r, fill, stroke, 1.0); + pub(super) fn rect(&self, b: Box4, r: f32, fill: C, stroke: C) { + self.rect_w(b, r, fill, stroke, 1.0); } fn circle_w(&self, c: P2, r: f32, fill: C, stroke: C, w: f32) { @@ -516,7 +521,7 @@ impl Pen<'_> { /// Landscape slide frame. pub(super) fn frame(&self, x0: f32, y0: f32, x1: f32, y1: f32) { - self.rect(x0, y0, x1, y1, 1.5, N, K); + self.rect((x0, y0, x1, y1), 1.5, N, K); } /// Document page with a folded top-right corner. @@ -530,7 +535,7 @@ impl Pen<'_> { pub(super) fn picture(&self, x0: f32, y0: f32, x1: f32, y1: f32) { let (w, h) = (x1 - x0, y1 - y0); let m = w.min(h); - self.rect(x0, y0, x1, y1, 1.5, N, K); + self.rect((x0, y0, x1, y1), 1.5, N, K); self.dot((x1 - w * 0.27, y0 + h * 0.3), m * 0.12, Y); let base = y1 - h * 0.14; self.poly(&[(x0 + w * 0.12, base), (x0 + w * 0.38, y0 + h * 0.36), (x0 + w * 0.64, base)], G, N); @@ -597,11 +602,11 @@ impl Pen<'_> { } /// Table grid; `header` fills the first row. - pub(super) fn table(&self, x0: f32, y0: f32, x1: f32, y1: f32, rows: usize, cols: usize, header: C) { + pub(super) fn table(&self, (x0, y0, x1, y1): Box4, rows: usize, cols: usize, header: C) { let rh = (y1 - y0) / rows as f32; let cw = (x1 - x0) / cols as f32; if !matches!(header, C::None) { - self.rect(x0, y0, x1, y0 + rh, 1.2, header, N); + self.rect((x0, y0, x1, y0 + rh), 1.2, header, N); } for i in 1..rows { let y = y0 + rh * i as f32; @@ -611,7 +616,7 @@ impl Pen<'_> { let x = x0 + cw * j as f32; self.seg_w((x, y0), (x, y1), K, 0.8); } - self.rect(x0, y0, x1, y1, 1.2, N, K); + self.rect((x0, y0, x1, y1), 1.2, N, K); } /// Bar chart in the box. @@ -620,13 +625,13 @@ impl Pen<'_> { let h = y1 - y0; for (i, (frac, c)) in [(0.5, B), (1.0, O), (0.72, G)].into_iter().enumerate() { let bx = x0 + w * i as f32; - self.rect(bx + w * 0.16, y1 - h * frac, bx + w * 0.84, y1, 0.6, c, N); + self.rect((bx + w * 0.16, y1 - h * frac, bx + w * 0.84, y1), 0.6, c, N); } self.seg((x0 - 0.5, y1 + 0.2), (x1 + 0.5, y1 + 0.2), K); } /// A writing tool lying along `ang` degrees with its point at `tip`. - pub(super) fn tool(&self, tip: P2, ang: f32, len: f32, hw: f32, cone: f32, body: C, lead: C, cap_len: f32, cap: C) { + pub(super) fn tool(&self, (tip, ang): (P2, f32), (len, hw, cone): (f32, f32, f32), body: C, lead: C, (cap_len, cap): (f32, C)) { let m = |pts: &[P2]| tf(tip, ang, pts); self.poly(&m(&[(0.0, 0.0), (cone, -hw), (cone, hw)]), N, K); self.poly(&m(&[(0.0, 0.0), (cone * 0.42, -hw * 0.42), (cone * 0.42, hw * 0.42)]), lead, N); @@ -714,7 +719,7 @@ impl Pen<'_> { } pub(super) fn monitor(&self, x0: f32, y0: f32, x1: f32, y1: f32) { - self.rect(x0, y0, x1, y1, 1.5, N, K); + self.rect((x0, y0, x1, y1), 1.5, N, K); let cx = (x0 + x1) * 0.5; self.seg((cx, y1), (cx, y1 + 3.2), K); self.seg((cx - 4.0, y1 + 3.6), (cx + 4.0, y1 + 3.6), K); @@ -749,7 +754,7 @@ impl Pen<'_> { sh.extend(arc_pts((cx, y0 - r * 0.7), r, r, 180.0, 360.0)); sh.push((cx + r, y0)); self.line(&sh, K); - self.rect(x0, y0, x1, y1, 1.2, Y, K); + self.rect((x0, y0, x1, y1), 1.2, Y, K); let ky = (y0 + y1) * 0.5; self.dot((cx, ky - 0.4), 1.1, D); self.seg_w((cx, ky), (cx, ky + 2.0), D, 0.9); diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index 956f2d9b..c44d3faf 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -348,7 +348,7 @@ impl MediaHost { /// Carry out a `media.*` request from the engine for presentation `doc_uid`. /// `slide` is the slide the request comes from: shape ids are only unique per slide. - pub fn request(&mut self, doc_uid: u64, doc: &Presentation, slide: usize, action: &str, id: ShapeId, ms: Option, owner: Owner) { + pub fn request(&mut self, doc_uid: u64, doc: &Presentation, slide: usize, action: &str, (id, ms): (ShapeId, Option), owner: Owner) { self.ensure_doc(doc_uid); let Some(clip) = find_clip(doc, id, Some(slide)) else { return }; let t = ms.map(|v| v as f64 / 1000.0); diff --git a/crates/ui-egui/src/panels/animation.rs b/crates/ui-egui/src/panels/animation.rs index 95eea93f..22d3c68e 100644 --- a/crates/ui-egui/src/panels/animation.rs +++ b/crates/ui-egui/src/panels/animation.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, AnimClass, AnimStart, Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, animtimeline, icons, json, paint_anim_tile, + pos2, run, theme, vec2, +}; /// Render the animation pane and route its controls through the engine's animation commands. pub(super) fn animation(app: &mut SlideApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/panels/background.rs b/crates/ui-egui/src/panels/background.rs index 0373616f..906408a9 100644 --- a/crates/ui-egui/src/panels/background.rs +++ b/crates/ui-egui/src/panels/background.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Fill, SlideApp, Ui, Value, cref_param, json, run, scheme, theme, vec2, widgets}; pub(super) fn background(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); @@ -156,7 +156,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo }; ui.horizontal(|ui| { let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment")); - let combo = egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| { for (k, l) in [ ("tl", "Top Left"), ("t", "Top"), @@ -183,7 +183,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo }; ui.horizontal(|ui| { let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror")); - let combo = egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| { for (k, l) in [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")] { if ui.selectable_label(flip == k, l).clicked() { run(app, "design.background", json!({"pictureProps": {"mirror": k}})); diff --git a/crates/ui-egui/src/panels/comments.rs b/crates/ui-egui/src/panels/comments.rs index 0b384579..edf0e8b8 100644 --- a/crates/ui-egui/src/panels/comments.rs +++ b/crates/ui-egui/src/panels/comments.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, CornerRadius, Sense, SlideApp, Stroke, Tokens, Ui, Value, json, run, theme, vec2}; /// Review โ€บ Compare: the revised copy's slide-level changes; click to go there, Accept to take it, /// Reject to drop it. diff --git a/crates/ui-egui/src/panels/designer.rs b/crates/ui-egui/src/panels/designer.rs index 422e0085..16000b7c 100644 --- a/crates/ui-egui/src/panels/designer.rs +++ b/crates/ui-egui/src/panels/designer.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, CornerRadius, Rect, Sense, SlideApp, Stroke, Tokens, Ui, json, pos2, run, vec2}; /// Design Ideas: offline suggestions โ€” the current slide in each theme and layout variations. /// Rendered idea thumbnails, kept until the deck, slide or pane width changes (rendering eight diff --git a/crates/ui-egui/src/panels/fillui.rs b/crates/ui-egui/src/panels/fillui.rs index 2410dc0a..5150b8f0 100644 --- a/crates/ui-egui/src/panels/fillui.rs +++ b/crates/ui-egui/src/panels/fillui.rs @@ -169,7 +169,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> }; ui.horizontal(|ui| { let field = ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Type")); - let combo = egui::ComboBox::from_id_salt((id, "kind")).selected_text(kind).width(120.0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt((id, "kind")).selected_text(kind).width(120.0).show_ui(ui, |ui| { for k in ["Linear", "Radial", "Rectangular", "Path"] { if ui.selectable_label(kind == k, k).clicked() && kind != k { let focus = match &g.shape { diff --git a/crates/ui-egui/src/panels/format_shape.rs b/crates/ui-egui/src/panels/format_shape.rs index 77b26bf4..8a4d2377 100644 --- a/crates/ui-egui/src/panels/format_shape.rs +++ b/crates/ui-egui/src/panels/format_shape.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Fill, Icon, ShapeKind, SlideApp, Tokens, Ui, color_button, cref_param, json, run, scheme, theme, vec2, widgets}; /// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s /// `transparency` parameter writes as an alpha transform. Anything else (no fill, a @@ -23,16 +23,7 @@ pub(super) fn fill_transparency_to_apply(shown_percent: f32, seed_percent: f32) Some(f64::from(shown) / 100.0) } -/// Render the shape-format pane with fill/line, effects, size/properties, text, and picture tabs. -pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; - let Some(sh) = st.selected_shapes().first().map(|s| (*s).clone()) else { - ui.label(egui::RichText::new("Select a shape to format it.").color(t.text_dim)); - return; - }; - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh); - let uid = st.uid; +fn format_tabs(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { // Tabs as icons. ui.horizontal(|ui| { for (tab, icon, tip) in [ @@ -52,100 +43,110 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { app.ui.format_tab = "picture".into(); } }); - ui.separator(); - match app.ui.format_tab.as_str() { - "effects" => { - ui.label(egui::RichText::new("Shadow").font(theme::bold(13.0))); - ui.horizontal_wrapped(|ui| { - for (l, v) in [ - ("None", "none"), - ("Outer", "outer"), - ("Bottom", "bottom"), - ("Center", "center"), - ("Inner", "inner"), - ("Perspective", "perspective"), - ] { - if ui.button(l).clicked() { - run(app, "shape.effects", json!({"shadow": v})); - } - } - }); - let fx = sh.effects.clone().unwrap_or_default(); - let shadow = fx.outer_shadow.as_ref().or(fx.inner_shadow.as_ref()); - if let Some(sw) = shadow { - color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"shadowProps": {"color": c.as_ref().map(cref_param)}}))); - fx_slider( - app, - ui, - ("shadow", sh.id.0, "t"), - "Transparency", - (1.0 - sw.color.alpha()) * 100.0, - 0.0..=100.0, - "%", - "shadowProps", - "transparency", - 0.01, - ); - fx_slider(app, ui, ("shadow", sh.id.0, "b"), "Blur", sw.blur, 0.0..=100.0, " pt", "shadowProps", "blur", 1.0); - fx_slider(app, ui, ("shadow", sh.id.0, "a"), "Angle", sw.dir, 0.0..=359.0, "ยฐ", "shadowProps", "dir", 1.0); - fx_slider(app, ui, ("shadow", sh.id.0, "d"), "Distance", sw.dist, 0.0..=200.0, " pt", "shadowProps", "dist", 1.0); - } - ui.add_space(6.0); - ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); - ui.horizontal_wrapped(|ui| { - for (l, v) in [("None", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { - if ui.button(l).clicked() { - run(app, "shape.effects", json!({"reflection": v})); - } - } - }); - if let Some(r) = &fx.reflection { - fx_slider( - app, - ui, - ("refl", sh.id.0, "t"), - "Transparency", - (1.0 - r.start_alpha) * 100.0, - 0.0..=100.0, - "%", - "reflectionProps", - "transparency", - 0.01, - ); - fx_slider(app, ui, ("refl", sh.id.0, "s"), "Size", r.end_pos * 100.0, 0.0..=100.0, "%", "reflectionProps", "size", 0.01); - fx_slider(app, ui, ("refl", sh.id.0, "d"), "Distance", r.dist, 0.0..=100.0, " pt", "reflectionProps", "dist", 1.0); - fx_slider(app, ui, ("refl", sh.id.0, "b"), "Blur", r.blur, 0.0..=100.0, " pt", "reflectionProps", "blur", 1.0); - } - ui.add_space(6.0); - ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); - let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius); - fx_slider(app, ui, ("glow", sh.id.0, "r"), "Size", radius, 0.0..=150.0, " pt", "glowProps", "radius", 1.0); - if let Some(g) = &fx.glow { - color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"glowProps": {"color": c.as_ref().map(cref_param)}}))); - fx_slider( - app, - ui, - ("glow", sh.id.0, "t"), - "Transparency", - (1.0 - g.color.alpha()) * 100.0, - 0.0..=100.0, - "%", - "glowProps", - "transparency", - 0.01, - ); - } - ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0))); - let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0); - if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { - run(app, "shape.effects", json!({"softEdges": s})); - } - let raw3d = sh.effects.as_ref().and_then(|e| e.raw3d.clone()); - three_d_options(app, ui, sh.id.0, raw3d.as_deref()); - if ui.button("Reset Effects").clicked() { - run(app, "shape.effects", json!({"reset": true})); +} + +fn effects_tab(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { + ui.label(egui::RichText::new("Shadow").font(theme::bold(13.0))); + ui.horizontal_wrapped(|ui| { + for (l, v) in + [("None", "none"), ("Outer", "outer"), ("Bottom", "bottom"), ("Center", "center"), ("Inner", "inner"), ("Perspective", "perspective")] + { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"shadow": v})); } } + }); + let fx = sh.effects.clone().unwrap_or_default(); + let shadow = fx.outer_shadow.as_ref().or(fx.inner_shadow.as_ref()); + if let Some(sw) = shadow { + color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"shadowProps": {"color": c.as_ref().map(cref_param)}}))); + fx_slider( + app, + ui, + ("shadow", sh.id.0, "t"), + "Transparency", + (1.0 - sw.color.alpha()) * 100.0, + 0.0..=100.0, + "%", + "shadowProps", + "transparency", + 0.01, + ); + fx_slider(app, ui, ("shadow", sh.id.0, "b"), "Blur", sw.blur, 0.0..=100.0, " pt", "shadowProps", "blur", 1.0); + fx_slider(app, ui, ("shadow", sh.id.0, "a"), "Angle", sw.dir, 0.0..=359.0, "ยฐ", "shadowProps", "dir", 1.0); + fx_slider(app, ui, ("shadow", sh.id.0, "d"), "Distance", sw.dist, 0.0..=200.0, " pt", "shadowProps", "dist", 1.0); + } + ui.add_space(6.0); + ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); + ui.horizontal_wrapped(|ui| { + for (l, v) in [("None", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"reflection": v})); + } + } + }); + if let Some(r) = &fx.reflection { + fx_slider( + app, + ui, + ("refl", sh.id.0, "t"), + "Transparency", + (1.0 - r.start_alpha) * 100.0, + 0.0..=100.0, + "%", + "reflectionProps", + "transparency", + 0.01, + ); + fx_slider(app, ui, ("refl", sh.id.0, "s"), "Size", r.end_pos * 100.0, 0.0..=100.0, "%", "reflectionProps", "size", 0.01); + fx_slider(app, ui, ("refl", sh.id.0, "d"), "Distance", r.dist, 0.0..=100.0, " pt", "reflectionProps", "dist", 1.0); + fx_slider(app, ui, ("refl", sh.id.0, "b"), "Blur", r.blur, 0.0..=100.0, " pt", "reflectionProps", "blur", 1.0); + } + ui.add_space(6.0); + ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); + let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius); + fx_slider(app, ui, ("glow", sh.id.0, "r"), "Size", radius, 0.0..=150.0, " pt", "glowProps", "radius", 1.0); + if let Some(g) = &fx.glow { + color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"glowProps": {"color": c.as_ref().map(cref_param)}}))); + fx_slider( + app, + ui, + ("glow", sh.id.0, "t"), + "Transparency", + (1.0 - g.color.alpha()) * 100.0, + 0.0..=100.0, + "%", + "glowProps", + "transparency", + 0.01, + ); + } + ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0))); + let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0); + if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { + run(app, "shape.effects", json!({"softEdges": s})); + } + let raw3d = sh.effects.as_ref().and_then(|e| e.raw3d.clone()); + three_d_options(app, ui, sh.id.0, raw3d.as_deref()); + if ui.button("Reset Effects").clicked() { + run(app, "shape.effects", json!({"reset": true})); + } +} + +/// Render the shape-format pane with fill/line, effects, size/properties, text, and picture tabs. +pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let Some(sh) = st.selected_shapes().first().map(|s| (*s).clone()) else { + ui.label(egui::RichText::new("Select a shape to format it.").color(t.text_dim)); + return; + }; + let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh); + let uid = st.uid; + format_tabs(app, ui, &sh); + ui.separator(); + match app.ui.format_tab.as_str() { + "effects" => effects_tab(app, ui, &sh), "size" => { let lock_id = egui::Id::unique("fmt-lock-aspect"); let mut lock: bool = ui.data_mut(|d| d.get_temp(lock_id).unwrap_or(false)); @@ -396,7 +397,7 @@ fn line_choice(app: &mut SlideApp, ui: &mut Ui, label: &str, key: &str, current: ui.horizontal(|ui| { let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); let shown = items.iter().find(|(_, v)| *v == current).map_or(current, |(l, _)| *l); - let combo = egui::ComboBox::from_id_salt(("fmt-line", key)).selected_text(shown).width(120.0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt(("fmt-line", key)).selected_text(shown).width(120.0).show_ui(ui, |ui| { for (l, v) in items { if ui.selectable_label(*v == current, *l).clicked() { run(app, "shape.line", json!({key: v})); @@ -542,7 +543,7 @@ fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft ui.horizontal(|ui| { let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); let shown = opts.iter().find(|(k, _)| k == cur).map_or(opts[0].1, |(_, l)| *l); - let combo = egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { for (k, l) in opts { if ui.selectable_label(*k == cur.as_str(), *l).clicked() { run(app, "shape.fill", json!({"pictureProps": {key: k}})); @@ -594,7 +595,7 @@ fn preset_label(name: &str) -> String { fn three_d_combo(app: &mut SlideApp, ui: &mut Ui, id: u32, label: &str, key: &str, cur: &str, options: &[&str]) { ui.horizontal(|ui| { let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - let combo = egui::ComboBox::from_id_salt(("3d", id, key)).selected_text(preset_label(cur)).width(170.0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt(("3d", id, key)).selected_text(preset_label(cur)).width(170.0).show_ui(ui, |ui| { for o in options { if ui.selectable_label(*o == cur, preset_label(o)).clicked() { run(app, "shape.effects", json!({"threeD": {key: o}})); diff --git a/crates/ui-egui/src/panels/selpane.rs b/crates/ui-egui/src/panels/selpane.rs index 048eff3f..b9325548 100644 --- a/crates/ui-egui/src/panels/selpane.rs +++ b/crates/ui-egui/src/panels/selpane.rs @@ -118,16 +118,7 @@ fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egu resp.on_hover_text(label) } -/// Render the Selection Pane and route visibility, order, rename, and selection actions through -/// engine commands. -pub fn show(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; - app.selpane.bind(st.uid); - let rows = rows(st.shapes(), &app.selpane.collapsed); - let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); - let has_sel = !selected.is_empty(); - +fn toolbar(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, has_sel: bool) { ui.horizontal(|ui| { if ui.add(egui::Button::new("Show All").stroke(Stroke::new(1.0, t.border))).clicked() { run(app, "selection.showAll", json!({})); @@ -144,15 +135,49 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } }); }); - ui.add_space(6.0); +} - // The list has the keyboard while one of its rows has focus. +fn focus_lock(ui: &mut Ui, rows: &[Row]) -> Option { let focused_row = ui.memory(|m| m.focused()).and_then(|f| rows.iter().find(|r| row_id(r.id) == f).map(|r| r.id)); if let Some(f) = focused_row { ui.memory_mut(|m| { m.set_focus_lock_filter(row_id(f), egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: true, escape: true }) }); } + focused_row +} + +fn group_chevron(app: &mut SlideApp, ui: &mut Ui, row: &Row, painter: &egui::Painter, x0: f32, r: Rect, t: &Tokens) { + let open = !app.selpane.collapsed.contains(&row.id); + let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); + let cresp = ui.interact(cr, ui.scope_id().with(("fold", row.id)), Sense::click()); + let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name); + cresp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &label)); + icons::paint(painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false); + if cresp.clicked() { + if open { + app.selpane.collapsed.insert(row.id); + } else { + app.selpane.collapsed.remove(&row.id); + } + } +} + +/// Render the Selection Pane and route visibility, order, rename, and selection actions through +/// engine commands. +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + app.selpane.bind(st.uid); + let rows = rows(st.shapes(), &app.selpane.collapsed); + let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); + let has_sel = !selected.is_empty(); + + toolbar(app, ui, &t, has_sel); + ui.add_space(6.0); + + // The list has the keyboard while one of its rows has focus. + let focused_row = focus_lock(ui, &rows); let mut rects: Vec = Vec::with_capacity(rows.len()); let width = ui.available_width(); for (k, row) in rows.iter().enumerate() { @@ -171,19 +196,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let x0 = r.min.x + 4.0 + INDENT * row.depth as f32; // Group chevron. if row.group { - let open = !app.selpane.collapsed.contains(&row.id); - let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); - let cresp = ui.interact(cr, ui.scope_id().with(("fold", row.id)), Sense::click()); - let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name); - cresp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &label)); - icons::paint(&painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false); - if cresp.clicked() { - if open { - app.selpane.collapsed.insert(row.id); - } else { - app.selpane.collapsed.remove(&row.id); - } - } + group_chevron(app, ui, row, &painter, x0, r, &t); } let name_x = x0 + 16.0; let eye = Rect::from_center_size(pos2(r.max.x - 34.0, r.center().y), vec2(16.0, 16.0)); diff --git a/crates/ui-egui/src/panels/smartart.rs b/crates/ui-egui/src/panels/smartart.rs index d21393e9..aa872d1f 100644 --- a/crates/ui-egui/src/panels/smartart.rs +++ b/crates/ui-egui/src/panels/smartart.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SlideApp, Tokens, Ui, Value, json, run}; /// SmartArt Text Pane: one line per node, indented by level. Enter adds a line after, Tab and /// Shift+Tab demote and promote, Backspace in an empty line removes it. diff --git a/crates/ui-egui/src/panels/thesaurus.rs b/crates/ui-egui/src/panels/thesaurus.rs index b79e8ddb..ca4e5133 100644 --- a/crates/ui-egui/src/panels/thesaurus.rs +++ b/crates/ui-egui/src/panels/thesaurus.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SlideApp, Tokens, Ui, json, run, theme}; /// Review โ€บ Thesaurus: look a word up; clicking a synonym puts it in place of the selected word /// when editing text, otherwise looks that synonym up in turn. diff --git a/crates/ui-egui/src/ribbon/animations.rs b/crates/ui-egui/src/ribbon/animations.rs index 0bdf1929..30dbb9e3 100644 --- a/crates/ui-egui/src/ribbon/animations.rs +++ b/crates/ui-egui/src/ribbon/animations.rs @@ -1,5 +1,8 @@ use super::transitions::option_label; -use super::*; +use super::{ + Align2, AnimClass, CornerRadius, Icon, Rect, Sense, SlideApp, Tokens, Ui, big, big_button, big_button_ex, dropdown, enabled, group, icons, json, + pos2, rows, run, theme, vec2, widgets, +}; // ---------- Animations ---------- diff --git a/crates/ui-egui/src/ribbon/arrange.rs b/crates/ui-egui/src/ribbon/arrange.rs index f5b517bf..0d340df4 100644 --- a/crates/ui-egui/src/ribbon/arrange.rs +++ b/crates/ui-egui/src/ribbon/arrange.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Icon, ShapeId, SlideApp, Ui, big, big_button, big_button_ex, enabled, group, json, menu_item, rows, run, small_button_ex, theme, widgets, +}; pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { ui.label(egui::RichText::new("Order Objects").font(theme::bold(12.0))); diff --git a/crates/ui-egui/src/ribbon/chart.rs b/crates/ui-egui/src/ribbon/chart.rs index 97431ab3..bf84c0f8 100644 --- a/crates/ui-egui/src/ribbon/chart.rs +++ b/crates/ui-egui/src/ribbon/chart.rs @@ -1,6 +1,6 @@ use super::arrange::arrange_group; use super::insert::chart_menu_types; -use super::*; +use super::{Icon, SlideApp, Ui, big, big_button, group, json}; pub(super) fn chart_design(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { diff --git a/crates/ui-egui/src/ribbon/design.rs b/crates/ui-egui/src/ribbon/design.rs index 18305914..7a81c98e 100644 --- a/crates/ui-egui/src/ribbon/design.rs +++ b/crates/ui-egui/src/ribbon/design.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, big, big_button, enabled, group, json, pos2, run, scheme, + small_button, theme, vec2, widgets, +}; // ---------- Design ---------- diff --git a/crates/ui-egui/src/ribbon/draw.rs b/crates/ui-egui/src/ribbon/draw.rs index d1a38541..592390e1 100644 --- a/crates/ui-egui/src/ribbon/draw.rs +++ b/crates/ui-egui/src/ribbon/draw.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, SlideApp, Ui, big_button, big_button_ex, group, json, run}; // ---------- Draw ---------- diff --git a/crates/ui-egui/src/ribbon/galleries.rs b/crates/ui-egui/src/ribbon/galleries.rs index 3feb1b74..41291062 100644 --- a/crates/ui-egui/src/ribbon/galleries.rs +++ b/crates/ui-egui/src/ribbon/galleries.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, CornerRadius, Rect, Sense, SlideApp, Stroke, Tokens, Ui, Value, json, pos2, run, scheme, theme, vec2, widgets}; /// A ColorRef as a command colour parameter. pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value { @@ -60,8 +60,7 @@ pub(super) fn smartart_gallery( cmd: &str, extra: Value, current: &str, - style: &str, - colors: &str, + (style, colors): (&str, &str), nodes: Option<&[deckcraft_model::DiagramNode]>, ) { use deckcraft_engine::cmd::smartart::{LAYOUTS, category}; @@ -86,7 +85,7 @@ pub(super) fn smartart_gallery( egui::Grid::new(("smartart_gallery", cat)).num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| { for (i, (k, label)) in kinds.iter().enumerate() { let ctx = ui.ctx().clone(); - let thumb = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, None, 192)); + let thumb = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, (k, style, colors), None, 192)); let r = ui .vertical_centered(|ui| { ui.set_width(100.0); @@ -102,7 +101,7 @@ pub(super) fn smartart_gallery( }) .inner; if r.hovered() { - let preview = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, nodes, 480)); + let preview = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, (k, style, colors), nodes, 480)); if let Some(t) = preview { r.show_tooltip_ui(|ui| { ui.add(egui::Image::new(&t).fit_to_exact_size(egui::vec2(320.0, 200.0))); diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 826fba98..1c27dc98 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -1,6 +1,9 @@ use super::arrange::arrange_menu; use super::galleries::{cref_param, effects_menu, outline_menu, quick_styles, shapes_gallery, smartart_gallery}; -use super::*; +use super::{ + Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, Value, big, big_button, big_button_ex, color_split, dropdown, enabled, + fmt_state, group, icon_toggle, json, pos2, rows, run, scheme, small, small_button, small_button_ex, small_button_named, toggle, vec2, widgets, +}; // ---------- Home ---------- @@ -382,7 +385,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } }); let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); - egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None)); + egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", ("", ""), None)); if ui.add_enabled(en, egui::Button::new("Paragraphโ€ฆ").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() { run(app, "format.paragraphDialog", json!({})); } diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index da2b6bb4..fab6e317 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -1,7 +1,10 @@ use super::galleries::{Fl, kurbo_flatten, shapes_gallery}; use super::home::layout_menu; use super::view::zoom_menu; -use super::*; +use super::{ + Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, big, big_button, big_button_ex, enabled, group, json, menu_item, pos2, + run, tip, vec2, +}; /// Photo Album is a split button: the main click starts a new album, the arrow edits one. fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/ribbon/masters.rs b/crates/ui-egui/src/ribbon/masters.rs index c39d2f15..ac33b352 100644 --- a/crates/ui-egui/src/ribbon/masters.rs +++ b/crates/ui-egui/src/ribbon/masters.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, SlideApp, Target, Ui, big, big_button, group, json, rows, run}; /// Preserve, and whether the open master or layout has a title or any footer placeholder. fn master_checks(app: &SlideApp) -> (bool, bool, bool) { diff --git a/crates/ui-egui/src/ribbon/picture_format.rs b/crates/ui-egui/src/ribbon/picture_format.rs index 73c3bde6..e2704f5a 100644 --- a/crates/ui-egui/src/ribbon/picture_format.rs +++ b/crates/ui-egui/src/ribbon/picture_format.rs @@ -1,6 +1,9 @@ use super::arrange::{arrange_group, size_group}; use super::galleries::{cref_param, shapes_gallery}; -use super::*; +use super::{ + Color32, CornerRadius, Icon, Rect, SlideApp, Stroke, Ui, Value, big, big_button, color_split, enabled, group, json, pos2, rows, run, scheme, + small, small_button_ex, theme, vec2, widgets, +}; /// Render the Picture Format tab's corrections, colour, artistic effects, transparency, styles, /// border, and picture actions. diff --git a/crates/ui-egui/src/ribbon/playback.rs b/crates/ui-egui/src/ribbon/playback.rs index 9322c76d..1b76f13b 100644 --- a/crates/ui-egui/src/ribbon/playback.rs +++ b/crates/ui-egui/src/ribbon/playback.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, ShapeKind, SlideApp, Ui, big, big_button, group, json, rows, run}; pub(super) fn playback(app: &mut SlideApp, ui: &mut Ui) { let m = app diff --git a/crates/ui-egui/src/ribbon/review.rs b/crates/ui-egui/src/ribbon/review.rs index 70bf04d6..7c5db24c 100644 --- a/crates/ui-egui/src/ribbon/review.rs +++ b/crates/ui-egui/src/ribbon/review.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, SlideApp, Ui, Value, big, big_button, big_button_ex, enabled, group, json, rows, run, small_button, tip}; fn comment_nav(app: &mut SlideApp, ui: &mut Ui, forward: bool) { let (icon, label, id) = if forward { (Icon::NextComment, "Next", "comment.next") } else { (Icon::PrevComment, "Previous", "comment.prev") }; diff --git a/crates/ui-egui/src/ribbon/shape_format.rs b/crates/ui-egui/src/ribbon/shape_format.rs index c8033552..1f648bd1 100644 --- a/crates/ui-egui/src/ribbon/shape_format.rs +++ b/crates/ui-egui/src/ribbon/shape_format.rs @@ -1,6 +1,9 @@ use super::arrange::{arrange_group, size_group}; use super::galleries::{cref_param, effects_menu, outline_menu, quick_styles, shapes_gallery}; -use super::*; +use super::{ + Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, big, big_button, enabled, group, json, pos2, rows, run, scheme, + small_button, small_button_ex, theme, vec2, widgets, +}; // ---------- contextual tabs ---------- diff --git a/crates/ui-egui/src/ribbon/slide_show.rs b/crates/ui-egui/src/ribbon/slide_show.rs index 8542f547..89c78c11 100644 --- a/crates/ui-egui/src/ribbon/slide_show.rs +++ b/crates/ui-egui/src/ribbon/slide_show.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, SlideApp, Ui, big, big_button, big_button_ex, group, json, rows, run}; // ---------- Slide Show / Record / Review / View ---------- diff --git a/crates/ui-egui/src/ribbon/smartart.rs b/crates/ui-egui/src/ribbon/smartart.rs index b8b38fbb..969cc614 100644 --- a/crates/ui-egui/src/ribbon/smartart.rs +++ b/crates/ui-egui/src/ribbon/smartart.rs @@ -1,6 +1,6 @@ use super::arrange::arrange_group; use super::galleries::smartart_gallery; -use super::*; +use super::{Icon, SlideApp, Ui, Value, big_button, group, json, rows, run, small_button, small_button_ex, widgets}; /// SmartArt Design: Create Graphic (Text Pane, Add Shape, Promote, Demote, Remove) and Layouts. pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { @@ -53,7 +53,7 @@ pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { let outline = sel.as_ref().map(|(_, d)| d.nodes.clone()); if let Some(arrow) = arrow { widgets::split_menu(&main, &arrow, |ui| { - smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, &style, &colors, outline.as_deref()); + smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, (&style, &colors), outline.as_deref()); }); } let (main, arrow) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); diff --git a/crates/ui-egui/src/ribbon/table.rs b/crates/ui-egui/src/ribbon/table.rs index b2c0fde8..8c49c354 100644 --- a/crates/ui-egui/src/ribbon/table.rs +++ b/crates/ui-egui/src/ribbon/table.rs @@ -1,6 +1,9 @@ use super::arrange::arrange_group; use super::galleries::cref_param; -use super::*; +use super::{ + Color32, CornerRadius, Icon, Rect, Sense, ShapeKind, SlideApp, Stroke, Ui, Value, big, big_button, big_button_ex, color_split, group, json, + menu_item, pos2, rows, run, scheme, small, theme, toggle, vec2, widgets, +}; pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) { let tbl = app diff --git a/crates/ui-egui/src/ribbon/title_bar.rs b/crates/ui-egui/src/ribbon/title_bar.rs index c8bdd351..6c7e825e 100644 --- a/crates/ui-egui/src/ribbon/title_bar.rs +++ b/crates/ui-egui/src/ribbon/title_bar.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Icon, Rect, Sense, SlideApp, Tokens, Ui, icon_toggle, json, pos2, theme, vec2}; pub const TITLE_H: f32 = 38.0; diff --git a/crates/ui-egui/src/ribbon/transitions.rs b/crates/ui-egui/src/ribbon/transitions.rs index 3417e657..3f43ebd9 100644 --- a/crates/ui-egui/src/ribbon/transitions.rs +++ b/crates/ui-egui/src/ribbon/transitions.rs @@ -1,5 +1,8 @@ use super::animations::marked_options; -use super::*; +use super::{ + Align2, Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, Value, big, big_button, group, json, pos2, rows, run, theme, + vec2, widgets, +}; // ---------- Transitions ---------- @@ -76,7 +79,7 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) { .and_then(|t| t.sound) .and_then(|m| app.session.active().and_then(|d| d.doc.media(m).map(|i| i.name.trim_end_matches(".wav").to_string()))) .unwrap_or_else(|| if stop { "[Stop Previous Sound]".into() } else { "[No Sound]".into() }); - let combo = egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { let none = cur.as_ref().is_none_or(|t| t.sound.is_none() && !t.stop_sound); if ui.selectable_label(none, "[No Sound]").clicked() { run(app, "transition.sound", json!({"none": true})); diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs index 8231f9a6..c67f1c80 100644 --- a/crates/ui-egui/src/ribbon/view.rs +++ b/crates/ui-egui/src/ribbon/view.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, SlideApp, Target, Ui, big_button, big_button_ex, group, json, rows, run, theme}; pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) { use crate::ViewMode; diff --git a/crates/ui-egui/src/show/keys.rs b/crates/ui-egui/src/show/keys.rs index 71322923..c7da76e2 100644 --- a/crates/ui-egui/src/show/keys.rs +++ b/crates/ui-egui/src/show/keys.rs @@ -1,6 +1,6 @@ use super::presenter::NOTES_STEP; use super::zoom::{plain, toggle_blank}; -use super::*; +use super::{Color32, Presentation, Show, ShowAction}; /// Read the current viewport's show keys before its widgets can consume them. Both the /// audience window and Presenter View use this path, including animation steps and end-of-show. diff --git a/crates/ui-egui/src/show/media.rs b/crates/ui-egui/src/show/media.rs index 72838396..ee8b1dec 100644 --- a/crates/ui-egui/src/show/media.rs +++ b/crates/ui-egui/src/show/media.rs @@ -1,5 +1,5 @@ use super::render::fit; -use super::*; +use super::{Click, HitShape, Hyperlink, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2}; /// The media-host voice for slide transition sounds (no shape has this id). pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX); diff --git a/crates/ui-egui/src/show/presenter.rs b/crates/ui-egui/src/show/presenter.rs index 17f78fa5..4b0c2529 100644 --- a/crates/ui-egui/src/show/presenter.rs +++ b/crates/ui-egui/src/show/presenter.rs @@ -1,7 +1,7 @@ use super::keys::keyboard; use super::render::{fit, next_rect}; use super::zoom::{Zoom, grid_indices, paint_grid, see_all_hit, slide_aspect, toggle_blank, zoom_frame}; -use super::*; +use super::{Align2, Color32, CornerRadius, Presentation, Rect, RenderOpts, Sense, Show, Stroke, Ui, pos2, theme, vec2}; pub(super) const NOTES_DEFAULT: f32 = 18.0; diff --git a/crates/ui-egui/src/show/render.rs b/crates/ui-egui/src/show/render.rs index bf278064..d6de7a9f 100644 --- a/crates/ui-egui/src/show/render.rs +++ b/crates/ui-egui/src/show/render.rs @@ -1,5 +1,8 @@ use super::zoom::GRID_CAP; -use super::*; +use super::{ + BackKey, Color32, Key, Layer, Mesh, Prefetch, Presentation, Rect, RenderOpts, ShapeId, Show, ShowState, StepStamp, TextureHandle, Timeline, + TriggerPlay, pos2, state_for, vec2, +}; pub(super) fn render_threads() -> u16 { if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) } diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 80f9bd86..ab0dda6e 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -8,7 +8,49 @@ use super::zoom::{ Zoom, grid_indices, norm_on_slide, paint_grid, see_all_hit, slide_aspect, slide_box, slide_to_screen, toggle_blank, tool_bar_rect, unit_uv, wheel_zoom_lines, }; -use super::*; +use super::{ + Align2, BackKey, Click, Color32, CornerRadius, Key, LinkJump, Pos2, Rect, RenderOpts, Sense, ShapeId, ShapeState, Show, ShowAction, SlideApp, + Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible, +}; +use deckcraft_anim::{StepStamp, TriggerPlay}; +use deckcraft_model::Presentation; + +/// Per-frame values the show's draw steps share. +#[derive(Clone, Copy)] +struct Fr<'a> { + ctx: &'a egui::Context, + doc: &'a std::sync::Arc, + doc_uid: u64, + now: f64, + full: Rect, + srect: Rect, + clicked: bool, + secondary: bool, + pointer: Option, +} + +/// Whether the show goes on, and the steps input asked for. +struct Out { + keep: bool, + actions: Vec, +} + +/// What is drawn on the current slide this frame. +struct Sl { + idx: usize, + step: usize, + animating: bool, + t: f64, + plays: Vec, + stamps: Vec, + media: Vec<(ShapeId, deckcraft_geom::Xfrm, deckcraft_model::MediaClip)>, + hidden: Vec, + times: Vec<(ShapeId, f64)>, + gifs: Vec, + key: Key, + back_key: BackKey, + size: (u32, u32), +} /// Draw and advance the active show, handling hidden frames, timing, input, transitions, and /// presenter/reading presentation. @@ -24,20 +66,51 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { // A forced hidden paint (for example a pending screenshot) draws the current frame; // the show clocks are advanced by logic, not by this draw, so it must not advance them a // second time. Keep drawing the last frame rather than returning a blank. - if show.started { - let full = ui.max_rect(); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - let srect = fit(avail, &doc); - let painter = ui.painter_at(full); - painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); - if let Some((_, tex)) = &show.tex { - painter.image(tex.id(), srect, unit_uv(), Color32::WHITE); - } - } + paint_hidden(ui, &doc, &show); app.show = Some(show); return; } show.observe_visible_frame(now, web_time::Instant::now()); + begin_show(&ctx, &mut show, &doc, now); + let mut out = Out { keep: keyboard(&ctx, &mut show, &doc, now, false), actions: vec![] }; + let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)); + if moved { + show.last_move = now; + } + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, &doc); + let pointer = ctx.input(|i| i.pointer.interact_pos()); + show.expire_goto(now); + // The clock of the triggered animations runs before anything reacts to the pointer. + show.tick_triggers(&doc, now); + let f = Fr { ctx: &ctx, doc: &doc, doc_uid, now, full, srect, clicked, secondary, pointer }; + let pointing = handle_clicks(app, ui, &mut show, &f, &mut out); + handle_wheel_and_swipe(&mut show, &f, &mut out); + advance(app, &mut show, &f, &mut out); + let painter = ui.painter_at(full); + painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); + draw_slide(app, ui, &mut show, &f, &painter); + draw_pen_and_hints(ui, &mut show, &f, &painter, pointing); + draw_bars(ui, &mut show, &f, &painter, &mut out); + draw_extras(&mut show, &f, &painter, &mut out); + finish(app, show, &f, out.keep); +} + +fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) { + if show.started { + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, doc); + let painter = ui.painter_at(full); + painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); + if let Some((_, tex)) = &show.tex { + painter.image(tex.id(), srect, unit_uv(), Color32::WHITE); + } + } +} + +fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) { if !show.started { show.started = true; show.slide_start = now; @@ -51,21 +124,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { if show_fills_screen(show.reading, &doc.show.show_type) { ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true)); } - show.after_enter(&doc, now); + show.after_enter(doc, now); } - let mut keep = keyboard(&ctx, &mut show, &doc, now, false); - let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)); - if moved { - show.last_move = now; - } - let mut actions: Vec = vec![]; - let full = ui.max_rect(); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - let srect = fit(avail, &doc); - let pointer = ctx.input(|i| i.pointer.interact_pos()); - show.expire_goto(now); - // The clock of the triggered animations runs before anything reacts to the pointer. - show.tick_triggers(&doc, now); +} + +fn handle_clicks(app: &mut SlideApp, ui: &mut Ui, show: &mut Show, f: &Fr, out: &mut Out) -> bool { + let Fr { ctx, doc, doc_uid, now, full, srect, clicked, secondary, pointer } = *f; let on_slide = !show.grid && !show.pen && !show.laser @@ -73,31 +137,28 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { && show.blank.is_none() && show.zoom.is_none() && !show.zoom_arm - && !show.reading_bar_hit(&ctx) - && !pointer_on_media_bar(&ctx, ui, &doc, &show, now); + && !show.reading_bar_hit(ctx) + && !pointer_on_media_bar(ctx, ui, doc, show, now); // The button is drawn only while the pointer is awake; a hidden button takes no click. - let gif_click = if clicked && on_slide && doc.show.show_media_controls && show.pointer_awake(now) { - gif_hit(&ctx, ui, &show, doc_uid, &doc, app) - } else { - None - }; - let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; - let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None }; + let gif_click = + if clicked && on_slide && doc.show.show_media_controls && show.pointer_awake(now) { gif_hit(ctx, ui, show, doc_uid, doc, app) } else { None }; + let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(ctx, ui, show, doc) } else { None }; + let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(ctx, ui, show, doc, now) } else { None }; let pointing = under.as_ref().is_some_and(|u| u.pick.is_some()); if let Some(id) = gif_click { let _ = app.run("media.gifPlay", json!({"id": id})); } else if let Some((id, clip)) = media_click { - app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show); + app.media.toggle(doc_uid, doc, id, &clip, crate::media::Owner::Show); } else if clicked && let Some(Pick::Text(l)) = under.as_ref().and_then(|u| u.pick.clone()) { let last = show.last_viewed; - match show.state.follow_link(&doc, &l, last) { - LinkJump::Show(a) => actions.push(a), + match show.state.follow_link(doc, &l, last) { + LinkJump::Show(a) => out.actions.push(a), LinkJump::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), - LinkJump::Exit => keep = false, + LinkJump::Exit => out.keep = false, LinkJump::None => {} } } else if show.grid && clicked { - let aspect = slide_aspect(&doc); + let aspect = slide_aspect(doc); if let Some(p) = pointer && let Some(n) = see_all_hit(full, grid_indices(doc.slides.len()).len(), aspect, p) && let Some(&i) = grid_indices(doc.slides.len()).get(n) @@ -106,36 +167,41 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.blank = None; show.zoom = None; show.zoom_arm = false; - actions.push(show.state.goto(&doc, i)); + out.actions.push(show.state.goto(doc, i)); } - } else if clicked && show.zoom_arm && !show.reading_bar_hit(&ctx) { + } else if clicked && show.zoom_arm && !show.reading_bar_hit(ctx) { if let Some(p) = pointer && let Some((nx, ny)) = norm_on_slide(srect, unit_uv(), p) { show.zoom = Some(Zoom::at(nx, ny)); show.zoom_arm = false; } - } else if clicked && show.zoom.is_some() && !show.pen && !show.reading_bar_hit(&ctx) { + } else if clicked && show.zoom.is_some() && !show.pen && !show.reading_bar_hit(ctx) { show.dismiss_zoom(); - } else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show, now) { + } else if clicked && !show.pen && !show.reading_bar_hit(ctx) && !pointer_on_media_bar(ctx, ui, doc, show, now) { if show.ended { - keep = false; + out.keep = false; } else if show.blank.is_some() { show.blank = None; } else { // A shape with an action or a triggered animation, or the background. let none = Under::default(); - match show.click_slide(&doc, under.as_ref().unwrap_or(&none), now) { - Click::Show(a) => actions.push(a), + match show.click_slide(doc, under.as_ref().unwrap_or(&none), now) { + Click::Show(a) => out.actions.push(a), Click::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), - Click::Exit => keep = false, + Click::Exit => out.keep = false, Click::Animated | Click::None => {} } } } if secondary && !show.ended && !show.grid && show.zoom.is_none() && !show.zoom_arm { - actions.push(show.state.prev(&doc)); + out.actions.push(show.state.prev(doc)); } + pointing +} + +fn handle_wheel_and_swipe(show: &mut Show, f: &Fr, out: &mut Out) { + let Fr { ctx, doc, now, srect, pointer, .. } = *f; // Ctrl+wheel (Cmd+wheel on a Mac) zooms around the pointer, as in PowerPoint (#1286). egui // keeps it out of `smooth_scroll_delta`, so it does not also turn the page below. let line_points = ctx.options(|o| o.input_options.line_scroll_speed); @@ -156,45 +222,303 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.last_swipe = now; if next { if show.ended { - keep = false; + out.keep = false; } else { - actions.push(show.state.next(&doc)); + out.actions.push(show.state.next(doc)); } } else { show.ended = false; - actions.push(show.state.prev(&doc)); + out.actions.push(show.state.prev(doc)); } } +} + +fn advance(app: &mut SlideApp, show: &mut Show, f: &Fr, out: &mut Out) { + let Fr { doc, doc_uid, now, .. } = *f; // Apply input first: a step started by this click must restart the clock before it is read. - for a in actions.drain(..) { - if !show.apply(a, &doc, now) { - keep = false; + for a in out.actions.drain(..) { + if !show.apply(a, doc, now) { + out.keep = false; } } // Step clock, shared with the hidden logic tick. - if !show.queue_clock_actions(&doc, now, &mut actions) { - keep = false; + if !show.queue_clock_actions(doc, now, &mut out.actions) { + out.keep = false; } - for a in actions { - if !show.apply(a, &doc, now) { - keep = false; + for a in std::mem::take(&mut out.actions) { + if !show.apply(a, doc, now) { + out.keep = false; } } if show.state.playing { let step = show.state.step; - show.cue_media(&doc, step, now - show.step_start); + show.cue_media(doc, step, now - show.step_start); } - show_media(app, &mut show, doc_uid, &doc); - run_media_cues(app, &mut show, doc_uid, &doc); - // Draw. - let painter = ui.painter_at(full); - painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); + show_media(app, show, doc_uid, doc); + run_media_cues(app, show, doc_uid, doc); +} + +fn begin_slide(app: &mut SlideApp, show: &Show, f: &Fr, size: (u32, u32)) -> Sl { + let Fr { ctx, doc, doc_uid, now, .. } = *f; + let idx = show.state.slide; + let step = show.state.step; + // A main step or a triggered animation is running: the picture changes every frame. + let animating = show.state.playing || show.state.trigger_playing(); + let t = if show.state.playing { now - show.step_start } else { 0.0 }; + let plays = show.state.trigger_plays(); + let stamps = show.state.trigger_stamps(); + let media = crate::media::slide_media(doc, idx); + let hidden: Vec = media + .iter() + .filter(|(id, _, clip)| clip.hide_while_not_playing && !app.media.status(*id).is_some_and(|s| s.state != deckcraft_media::PlayState::Ended)) + .map(|m| m.0) + .collect(); + let gifs = app.media.gifs(doc_uid, doc, idx, now, &app.session.gif_paused); + let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); + crate::media::gif_repaint(ctx, &gifs); + let gif = crate::media::gif_key(&gifs); + let done: Vec<(ShapeId, usize)> = plays.iter().map(|p| (p.trigger, p.step)).collect(); + let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(doc) as usize, hidden: hidden.clone(), plays: done, gif }; + let back_key = BackKey { slide: idx, size, doc: key.doc }; + Sl { idx, step, animating, t, plays, stamps, media, hidden, times, gifs, key, back_key, size } +} + +fn frame_texture(show: &mut Show, f: &Fr, sl: &Sl) -> Option { + let Fr { ctx, doc, .. } = *f; + let Sl { idx, step, animating, size, .. } = *sl; + if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == sl.key) { + show.tex.as_ref().map(|(_, t)| t.clone()) + } else { + // The frame rendered ahead of time, when it is this one (waiting for it beats starting over). + let ready = match show.prefetch.take() { + Some(p) if p.key == sl.key && !animating => p.rx.recv().ok(), + _ => None, + }; + let ci = match ready { + Some(ci) => ci, + None => { + let tl = show.timeline(doc).clone(); + // Animating: the backdrop doesn't change, only the shapes over it are drawn. + let back = animating.then(|| show.backdrop(doc, sl.back_key)); + render_frame(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &sl.times, render_threads(), &sl.plays, &sl.stamps, back.as_deref()) + } + }; + let tex = match show.tex.take() { + Some((_, mut h)) => { + crate::textures::replace(ctx, &mut h, ci); + h + } + None => crate::textures::upload(ctx, "show", ci), + }; + show.tex = Some((sl.key.clone(), tex.clone())); + Some(tex) + } +} + +fn prefetch_next(app: &mut SlideApp, show: &mut Show, f: &Fr, sl: &Sl) { + let Fr { doc, doc_uid, now, .. } = *f; + let Sl { animating, size, .. } = *sl; + // While this slide rests, render the next one's first frame (not one with GIFs, whose + // frame depends on when it is entered). + // A slide with animations gets its backdrop ready while it rests, so they start smoothly. + if !animating && !show.timeline(doc).is_empty() { + show.prefetch_backdrop(doc, sl.back_key); + } + if !animating && show.trans.is_none() && !show.preview_only { + let next = show.state.peek_next(doc); + let wanted = next.filter(|n| app.media.gifs(doc_uid, doc, *n, now, &app.session.gif_paused).is_empty()); + if let Some(n) = wanted + && show.prefetch.as_ref().is_none_or(|p| p.key.slide != n || p.key.size != size || p.key.doc != std::sync::Arc::as_ptr(doc) as usize) + { + show.prefetch = start_prefetch(doc, n, size); + } + } +} + +fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, tex: &Option) -> bool { + let Fr { ctx, doc, now, srect, .. } = *f; + let Sl { idx, size, .. } = *sl; + let mut drew = false; + let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur); + let tl = if morphing { Some(show.timeline(doc).clone()) } else { None }; + let morph = match (&show.trans, tl) { + (Some(tr), Some(tl)) => { + let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); + let hidden_b = |id: ShapeId| { + let st = state_for(&tl, id, 0, 0.0, &[], &[]); + !st.visible || st.opacity <= 0.0 || sl.hidden.contains(&id) + }; + tr.from + .and_then(|i| doc.slides.get(i).map(|a| (i, a))) + .zip(doc.slides.get(idx)) + .map(|((i, a), b)| (i, deckcraft_anim::morph_frame(doc, a, b, p, &hidden_b))) + } + _ => None, + }; + if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) { + let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() }; + let threads = if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; + let opts = + RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() }; + let text: Vec = + m.text.iter().map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }).collect(); + let paths: Vec = m + .paths + .iter() + .map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) + .collect(); + let img = deckcraft_render::render_blend(doc, (from, idx), m.mix, &m.shapes, &text, &paths, &opts); + let ci = crate::textures::to_color_image(&img, false); + let frame = match tr.morph.take() { + Some(mut h) => { + crate::textures::replace(ctx, &mut h, ci); + h + } + None => crate::textures::upload(ctx, "morph", ci), + }; + painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + tr.morph = Some(frame); + drew = true; + ctx.request_repaint(); + } else if let (Some(tr), Some(new)) = (show.trans.as_mut(), &tex) { + let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); + if p < 1.0 { + // Slides split into background and content are rendered once per transition; + // every frame only composites cached textures. + if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { + tr.parts = Some(part_textures(ctx, doc, tr.from, idx, size)); + } + let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); + let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); + let parts = [part(0), part(1), part(2), part(3)]; + for l in deckcraft_anim::transition_frame(&tr.kind, &tr.option, p, aspect) { + let old_or_black = |t: Option<&TextureHandle>| match t { + Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), + None => quad_mesh(None, srect, &l, Color32::BLACK), + }; + let mesh = match l.source { + Source::Old => old_or_black(tr.old.as_ref()), + Source::New => quad_mesh(Some(new.id()), srect, &l, Color32::WHITE), + Source::Black => quad_mesh(None, srect, &l, Color32::BLACK), + Source::White => quad_mesh(None, srect, &l, Color32::WHITE), + Source::OldBack => old_or_black(parts[0].as_ref().or(tr.old.as_ref())), + Source::NewBack => quad_mesh(Some(parts[2].as_ref().unwrap_or(new).id()), srect, &l, Color32::WHITE), + Source::OldContent => match &parts[1] { + Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), + None => continue, + }, + Source::NewContent => match &parts[3] { + Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), + None => continue, + }, + }; + painter.add(egui::Shape::mesh(mesh)); + } + drew = true; + ctx.request_repaint(); + } + } + drew +} + +fn draw_videos(app: &mut SlideApp, show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, tex: &Option) -> (Vec, bool) { + let Fr { doc, full, srect, .. } = *f; + if let Some(tex) = &tex { + painter.image(tex.id(), srect, show.zoom_uv(), Color32::WHITE); + } + // Playing video: the current frame in its box, or full screen. + let (sw, sh) = (doc.slide_size.width as f32, doc.slide_size.height as f32); + let uv = show.zoom_uv(); + let mut framed: Vec = vec![]; + let mut full_screen = false; + for (id, x, clip) in &sl.media { + if !clip.video || sl.hidden.contains(id) { + continue; + } + let r = if clip.full_screen && app.media.is_playing(*id) { + full_screen = true; + full + } else { + slide_box(srect, uv, (sw, sh), (x.x as f32, x.y as f32, x.w as f32, x.h as f32)) + }; + if crate::media::paint_frame(painter, &app.media, *id, r, clip.crop) { + framed.push(*id); + } + } + (framed, full_screen) +} + +fn draw_overlay(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, framed: &[ShapeId], full_screen: bool) { + let Fr { ctx, doc, srect, .. } = *f; + let Sl { idx, step, animating, size, .. } = *sl; + let uv = show.zoom_uv(); + // Shapes stacked above a playing video show over it, as in PowerPoint (#1005); a video + // playing full screen covers the whole slide. + let skip = if full_screen { vec![] } else { below_playing_video(doc, idx, framed) }; + if skip.is_empty() { + show.overlay = None; + } else { + let fresh = !animating && show.overlay.as_ref().is_some_and(|(k, s, _)| *k == sl.key && *s == skip); + if !fresh { + let tl = show.timeline(doc).clone(); + let ci = render_overlay(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &skip, &sl.times, render_threads(), &sl.plays, &sl.stamps); + let handle = match show.overlay.take() { + Some((_, _, mut h)) => { + crate::textures::replace(ctx, &mut h, ci); + h + } + None => crate::textures::upload(ctx, "show-overlay", ci), + }; + show.overlay = Some((sl.key.clone(), skip, handle)); + } + if let Some((_, _, h)) = &show.overlay { + painter.image(h.id(), srect, uv, Color32::WHITE); + } + } +} + +fn draw_media_controls(app: &mut SlideApp, ui: &mut Ui, show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter) { + let Fr { ctx, doc, doc_uid, now, srect, .. } = *f; + let sw = doc.slide_size.width as f32; + // Play/pause on the animated GIF under the pointer, and the audio/video bar. + // Slide Show โ–ธ Show Media Controls hides both (the click still sl.plays a clip). + // Not while zoomed: the controls sit where the whole slide would be. + if doc.show.show_media_controls && !show.zoom_arm && show.zoom.is_none() { + let k = srect.width() / sw.max(1.0); + if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && show.pointer_awake(now)) { + for g in &sl.gifs { + let r = on_screen(srect, k, &g.rect); + if r.contains(p) { + let b = crate::media::gif_button_rect(r); + crate::media::paint_gif_button(painter, b, g.paused, b.contains(p)); + } + } + } + if let Some((id, clip, bar)) = media_bar_target(ctx, doc, show, srect).filter(|_| show.pointer_awake(now)) { + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + let action = { + let status = app.media.status(id); + crate::media::control_bar(ui, bar, status.as_ref(), &clip) + }; + let pos = app.media.status(id).map(|s| s.position).unwrap_or(0.0); + match action { + Some(crate::media::BarAction::Toggle) => app.media.toggle(doc_uid, doc, id, &clip, crate::media::Owner::Show), + Some(crate::media::BarAction::Seek(t)) => app.media.seek(doc_uid, doc, id, &clip, t, crate::media::Owner::Show), + Some(crate::media::BarAction::Nudge(dt)) => app.media.seek(doc_uid, doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Show), + None => {} + } + } + } +} + +fn draw_slide(app: &mut SlideApp, ui: &mut Ui, show: &mut Show, f: &Fr, painter: &egui::Painter) { + let Fr { ctx, full, srect, .. } = *f; let ppp = ctx.pixels_per_point(); // A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit // (drawn stretched to the screen). let size = crate::textures::fit_size( ((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32), - crate::textures::max_side(&ctx), + crate::textures::max_side(ctx), ); if show.ended { painter.text( @@ -205,237 +529,23 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { Color32::from_gray(200), ); } else { - let idx = show.state.slide; - let step = show.state.step; - // A main step or a triggered animation is running: the picture changes every frame. - let animating = show.state.playing || show.state.trigger_playing(); - let t = if show.state.playing { now - show.step_start } else { 0.0 }; - let plays = show.state.trigger_plays(); - let stamps = show.state.trigger_stamps(); - let media = crate::media::slide_media(&doc, idx); - let hidden: Vec = media - .iter() - .filter(|(id, _, clip)| { - clip.hide_while_not_playing && !app.media.status(*id).is_some_and(|s| s.state != deckcraft_media::PlayState::Ended) - }) - .map(|m| m.0) - .collect(); - let gifs = app.media.gifs(doc_uid, &doc, idx, now, &app.session.gif_paused); - let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); - crate::media::gif_repaint(&ctx, &gifs); - let gif = crate::media::gif_key(&gifs); - let done: Vec<(ShapeId, usize)> = plays.iter().map(|p| (p.trigger, p.step)).collect(); - let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), plays: done, gif }; - let back_key = BackKey { slide: idx, size, doc: key.doc }; - let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) { - show.tex.as_ref().map(|(_, t)| t.clone()) - } else { - // The frame rendered ahead of time, when it is this one (waiting for it beats starting over). - let ready = match show.prefetch.take() { - Some(p) if p.key == key && !animating => p.rx.recv().ok(), - _ => None, - }; - let ci = match ready { - Some(ci) => ci, - None => { - let tl = show.timeline(&doc).clone(); - // Animating: the backdrop doesn't change, only the shapes over it are drawn. - let back = animating.then(|| show.backdrop(&doc, back_key)); - render_frame(&doc, idx, tl, step, t, size, hidden.clone(), ×, render_threads(), &plays, &stamps, back.as_deref()) - } - }; - let tex = match show.tex.take() { - Some((_, mut h)) => { - crate::textures::replace(&ctx, &mut h, ci); - h - } - None => crate::textures::upload(&ctx, "show", ci), - }; - show.tex = Some((key.clone(), tex.clone())); - Some(tex) - }; - // While this slide rests, render the next one's first frame (not one with GIFs, whose - // frame depends on when it is entered). - // A slide with animations gets its backdrop ready while it rests, so they start smoothly. - if !animating && !show.timeline(&doc).is_empty() { - show.prefetch_backdrop(&doc, back_key); - } - if !animating && show.trans.is_none() && !show.preview_only { - let next = show.state.peek_next(&doc); - let wanted = next.filter(|n| app.media.gifs(doc_uid, &doc, *n, now, &app.session.gif_paused).is_empty()); - if let Some(n) = wanted - && show.prefetch.as_ref().is_none_or(|p| p.key.slide != n || p.key.size != size || p.key.doc != std::sync::Arc::as_ptr(&doc) as usize) - { - show.prefetch = start_prefetch(&doc, n, size); - } - } - let mut drew = false; - let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur); - let tl = if morphing { Some(show.timeline(&doc).clone()) } else { None }; - let morph = match (&show.trans, tl) { - (Some(tr), Some(tl)) => { - let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); - let hidden_b = |id: ShapeId| { - let st = state_for(&tl, id, 0, 0.0, &[], &[]); - !st.visible || st.opacity <= 0.0 || hidden.contains(&id) - }; - tr.from - .and_then(|i| doc.slides.get(i).map(|a| (i, a))) - .zip(doc.slides.get(idx)) - .map(|((i, a), b)| (i, deckcraft_anim::morph_frame(&doc, a, b, p, &hidden_b))) - } - _ => None, - }; - if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) { - let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() }; - let threads = - if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; - let opts = - RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() }; - let text: Vec = m - .text - .iter() - .map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }) - .collect(); - let paths: Vec = m - .paths - .iter() - .map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) - .collect(); - let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &paths, &opts); - let ci = crate::textures::to_color_image(&img, false); - let frame = match tr.morph.take() { - Some(mut h) => { - crate::textures::replace(&ctx, &mut h, ci); - h - } - None => crate::textures::upload(&ctx, "morph", ci), - }; - painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - tr.morph = Some(frame); - drew = true; - ctx.request_repaint(); - } else if let (Some(tr), Some(new)) = (show.trans.as_mut(), &tex) { - let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); - if p < 1.0 { - // Slides split into background and content are rendered once per transition; - // every frame only composites cached textures. - if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { - tr.parts = Some(part_textures(&ctx, &doc, tr.from, idx, size)); - } - let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); - let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); - let parts = [part(0), part(1), part(2), part(3)]; - for l in deckcraft_anim::transition_frame(&tr.kind, &tr.option, p, aspect) { - let old_or_black = |t: Option<&TextureHandle>| match t { - Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), - None => quad_mesh(None, srect, &l, Color32::BLACK), - }; - let mesh = match l.source { - Source::Old => old_or_black(tr.old.as_ref()), - Source::New => quad_mesh(Some(new.id()), srect, &l, Color32::WHITE), - Source::Black => quad_mesh(None, srect, &l, Color32::BLACK), - Source::White => quad_mesh(None, srect, &l, Color32::WHITE), - Source::OldBack => old_or_black(parts[0].as_ref().or(tr.old.as_ref())), - Source::NewBack => quad_mesh(Some(parts[2].as_ref().unwrap_or(new).id()), srect, &l, Color32::WHITE), - Source::OldContent => match &parts[1] { - Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), - None => continue, - }, - Source::NewContent => match &parts[3] { - Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), - None => continue, - }, - }; - painter.add(egui::Shape::mesh(mesh)); - } - drew = true; - ctx.request_repaint(); - } - } - if !drew { + let sl = begin_slide(app, show, f, size); + let tex = frame_texture(show, f, &sl); + prefetch_next(app, show, f, &sl); + if !draw_transition(show, f, &sl, painter, &tex) { show.trans = None; - if let Some(tex) = &tex { - painter.image(tex.id(), srect, show.zoom_uv(), Color32::WHITE); - } - // Playing video: the current frame in its box, or full screen. - let (sw, sh) = (doc.slide_size.width as f32, doc.slide_size.height as f32); - let uv = show.zoom_uv(); - let mut framed: Vec = vec![]; - let mut full_screen = false; - for (id, x, clip) in &media { - if !clip.video || hidden.contains(id) { - continue; - } - let r = if clip.full_screen && app.media.is_playing(*id) { - full_screen = true; - full - } else { - slide_box(srect, uv, sw, sh, x.x as f32, x.y as f32, x.w as f32, x.h as f32) - }; - if crate::media::paint_frame(&painter, &app.media, *id, r, clip.crop) { - framed.push(*id); - } - } - // Shapes stacked above a playing video show over it, as in PowerPoint (#1005); a video - // playing full screen covers the whole slide. - let skip = if full_screen { vec![] } else { below_playing_video(&doc, idx, &framed) }; - if skip.is_empty() { - show.overlay = None; - } else { - let fresh = !animating && show.overlay.as_ref().is_some_and(|(k, s, _)| *k == key && *s == skip); - if !fresh { - let tl = show.timeline(&doc).clone(); - let ci = render_overlay(&doc, idx, tl, step, t, size, hidden.clone(), &skip, ×, render_threads(), &plays, &stamps); - let handle = match show.overlay.take() { - Some((_, _, mut h)) => { - crate::textures::replace(&ctx, &mut h, ci); - h - } - None => crate::textures::upload(&ctx, "show-overlay", ci), - }; - show.overlay = Some((key.clone(), skip, handle)); - } - if let Some((_, _, h)) = &show.overlay { - painter.image(h.id(), srect, uv, Color32::WHITE); - } - } - // Play/pause on the animated GIF under the pointer, and the audio/video bar. - // Slide Show โ–ธ Show Media Controls hides both (the click still plays a clip). - // Not while zoomed: the controls sit where the whole slide would be. - if doc.show.show_media_controls && !show.zoom_arm && show.zoom.is_none() { - let k = srect.width() / sw.max(1.0); - if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && show.pointer_awake(now)) { - for g in &gifs { - let r = on_screen(srect, k, &g.rect); - if r.contains(p) { - let b = crate::media::gif_button_rect(r); - crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p)); - } - } - } - if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect).filter(|_| show.pointer_awake(now)) { - ctx.request_repaint_after(std::time::Duration::from_millis(500)); - let action = { - let status = app.media.status(id); - crate::media::control_bar(ui, bar, status.as_ref(), &clip) - }; - let pos = app.media.status(id).map(|s| s.position).unwrap_or(0.0); - match action { - Some(crate::media::BarAction::Toggle) => app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show), - Some(crate::media::BarAction::Seek(t)) => app.media.seek(doc_uid, &doc, id, &clip, t, crate::media::Owner::Show), - Some(crate::media::BarAction::Nudge(dt)) => { - app.media.seek(doc_uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Show) - } - None => {} - } - } - } + let (framed, full_screen) = draw_videos(app, show, f, &sl, painter, &tex); + draw_overlay(show, f, &sl, painter, &framed, full_screen); + draw_media_controls(app, ui, show, f, &sl, painter); } - if animating { + if sl.animating { ctx.request_repaint(); } } +} + +fn draw_pen_and_hints(ui: &mut Ui, show: &mut Show, f: &Fr, painter: &egui::Painter, pointing: bool) { + let Fr { ctx, doc, now, full, srect, .. } = *f; if let Some(c) = show.blank { painter.rect_filled(full, CornerRadius::ZERO, c); } @@ -500,6 +610,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { Color32::WHITE, ); } +} + +fn draw_bars(ui: &mut Ui, show: &mut Show, f: &Fr, painter: &egui::Painter, out: &mut Out) { + let Fr { ctx, doc, now, full, .. } = *f; // Reading view bar / on-screen controls. if show.reading { let bar = Rect::from_min_max(pos2(full.min.x, full.max.y - 34.0), full.max); @@ -517,17 +631,17 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { .layout(egui::Layout::right_to_left(egui::Align::Center)), ); if child.button("Close").clicked() { - keep = false; + out.keep = false; } if child.button("โ–ถ").clicked() { - let a = show.state.next(&doc); - if !show.apply(a, &doc, now) { - keep = false; + let a = show.state.next(doc); + if !show.apply(a, doc, now) { + out.keep = false; } } if child.button("โ—€").clicked() { - let a = show.state.prev(&doc); - show.apply(a, &doc, now); + let a = show.state.prev(doc); + show.apply(a, doc, now); } } else if !show.grid && (show.pointer_awake(now) || show.zoom_arm) { let bar = tool_bar_rect(full); @@ -536,13 +650,13 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); let b = |ui: &mut Ui, s: &str| ui.add(egui::Button::new(egui::RichText::new(s).color(Color32::WHITE).size(15.0)).frame(false)); if b(&mut child, "โ—€").clicked() { - let a = show.state.prev(&doc); - show.apply(a, &doc, now); + let a = show.state.prev(doc); + show.apply(a, doc, now); } if b(&mut child, "โ–ถ").clicked() { - let a = show.state.next(&doc); - if !show.apply(a, &doc, now) { - keep = false; + let a = show.state.next(doc); + if !show.apply(a, doc, now) { + out.keep = false; } } if b(&mut child, if show.pen { "โœ on" } else { "โœ" }).clicked() { @@ -552,10 +666,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.set_laser(!show.laser); } if b(&mut child, "Black").clicked() { - toggle_blank(&mut show, Color32::BLACK); + toggle_blank(show, Color32::BLACK); } if b(&mut child, "White").clicked() { - toggle_blank(&mut show, Color32::WHITE); + toggle_blank(show, Color32::WHITE); } if b(&mut child, "All").clicked() { show.grid = !show.grid; @@ -564,12 +678,16 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.zoom_key(); } if b(&mut child, "End").clicked() { - keep = false; + out.keep = false; } ctx.request_repaint_after(std::time::Duration::from_millis(300)); } +} + +fn draw_extras(show: &mut Show, f: &Fr, painter: &egui::Painter, out: &mut Out) { + let Fr { ctx, doc, now, full, .. } = *f; if show.grid && !show.ended { - paint_grid(&painter, &ctx, &mut show, &doc, full); + paint_grid(painter, ctx, show, doc, full); } if show.rehearse { let t = now - show.slide_start; @@ -586,9 +704,13 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } // Presenter view in a second window. - if keep && show.presenter && !show.reading { - presenter_window(&ctx, &mut show, &doc, now, &mut keep); + if out.keep && show.presenter && !show.reading { + presenter_window(ctx, show, doc, now, &mut out.keep); } +} + +fn finish(app: &mut SlideApp, mut show: Show, f: &Fr, keep: bool) { + let Fr { ctx, now, .. } = *f; if keep { app.show = Some(show); } else { diff --git a/crates/ui-egui/src/show/zoom.rs b/crates/ui-egui/src/show/zoom.rs index 97d306a4..9c45fd4a 100644 --- a/crates/ui-egui/src/show/zoom.rs +++ b/crates/ui-egui/src/show/zoom.rs @@ -1,6 +1,6 @@ use super::presenter::{NOTES_MAX, NOTES_MIN}; use super::render::render_thumb; -use super::*; +use super::{Align2, Color32, CornerRadius, Pos2, Presentation, Rect, Show, Stroke, json, pos2, theme, vec2}; impl Show { pub(super) fn zoom_uv(&self) -> Rect { @@ -344,7 +344,7 @@ pub(super) fn slide_to_screen(srect: Rect, uv: Rect, nx: f32, ny: f32) -> Pos2 { pos2(srect.min.x + u * srect.width(), srect.min.y + v * srect.height()) } -pub(super) fn slide_box(srect: Rect, uv: Rect, slide_w: f32, slide_h: f32, x: f32, y: f32, w: f32, h: f32) -> Rect { +pub(super) fn slide_box(srect: Rect, uv: Rect, (slide_w, slide_h): (f32, f32), (x, y, w, h): (f32, f32, f32, f32)) -> Rect { let a = slide_to_screen(srect, uv, x / slide_w.max(1.0), y / slide_h.max(1.0)); let b = slide_to_screen(srect, uv, (x + w) / slide_w.max(1.0), (y + h) / slide_h.max(1.0)); Rect::from_min_max(a, b) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4f36d8d1..e7b1c054 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, LayoutId, MasterId, Serialize, SlideId, Target, qat, theme}; /// The operation the user asked for. Kept with the request so a late completion does not /// guess a command from the file name. diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index c6643c20..19a4b4f6 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -65,15 +65,13 @@ impl Textures { } /// A SmartArt layout drawn small for a gallery (`nodes` for a preview of the selected - /// graphic's own outline), cached; drawn with the active presentation's theme. + /// graphic's own outline), cached; `look` is the layout kind, style and colours; drawn with the active presentation's theme. pub fn smartart( &mut self, ctx: &egui::Context, p: &deckcraft_model::Presentation, slide: usize, - kind: &str, - style: &str, - colors: &str, + (kind, style, colors): (&str, &str, &str), nodes: Option<&[deckcraft_model::DiagramNode]>, width_px: u32, ) -> Option { diff --git a/crates/vp9/src/header.rs b/crates/vp9/src/header.rs index 82f28e28..11efdb20 100644 --- a/crates/vp9/src/header.rs +++ b/crates/vp9/src/header.rs @@ -219,6 +219,156 @@ fn setup_past_independence(st: &mut HeaderState, fc: &mut FrameContext) { *fc = FrameContext::default(); } +/// The intra-only branch of the uncompressed header (6.2). +fn parse_intra_only_header(r: &mut BitReader, h: &mut FrameHeader) -> Result<()> { + frame_sync_code(r)?; + h.color = if h.profile > 0 { + color_config(r, h.profile)? + } else { + ColorConfig { bit_depth: 8, color_space: 1, color_range: false, subsampling_x: true, subsampling_y: true } + }; + h.refresh_frame_flags = r.read_bits(8)? as u8; + frame_size(r, h)?; + render_size(r, h) +} + +/// The inter-frame branch of the uncompressed header (6.2). +fn parse_inter_header(r: &mut BitReader, h: &mut FrameHeader, color: ColorConfig, refs: &[Option; 8]) -> Result<()> { + h.color = color; + h.refresh_frame_flags = r.read_bits(8)? as u8; + for i in 0..3 { + h.ref_frame_idx[i] = r.read_bits(3)? as u8; + h.ref_frame_sign_bias[LAST_FRAME as usize + i] = r.read_flag()?; + } + frame_size_with_refs(r, h, refs)?; + render_size(r, h)?; + h.allow_high_precision_mv = r.read_flag()?; + // read_interpolation_filter (6.2.7) + h.interp_filter = if r.read_flag()? { SWITCHABLE } else { LITERAL_TO_TYPE[r.read_bits(2)? as usize] }; + Ok(()) +} + +/// frame_size_with_refs (6.2.5). +fn frame_size_with_refs(r: &mut BitReader, h: &mut FrameHeader, refs: &[Option; 8]) -> Result<()> { + let mut found = false; + for i in 0..3 { + if r.read_flag()? { + let idx = h.ref_frame_idx[i] as usize; + let Some(info) = refs[idx] else { + return Err(Error::MissingReference(format!("frame size from empty slot {idx}"))); + }; + h.width = info.width; + h.height = info.height; + found = true; + break; + } + } + if !found { + frame_size(r, h)?; + } + h.size_from_ref = found; + Ok(()) +} + +/// Resets the saved probability contexts for intra and error-resilient frames (7.2). +fn reset_contexts(st: &mut HeaderState, h: &mut FrameHeader) { + let mut fc = FrameContext::default(); + setup_past_independence(st, &mut fc); + if h.frame_type == KEY_FRAME || h.error_resilient_mode || h.reset_frame_context == 3 { + for c in st.contexts.iter_mut() { + *c = fc.clone(); + } + } else if h.reset_frame_context == 2 { + st.contexts[h.frame_context_idx as usize] = fc.clone(); + } + h.frame_context_idx = 0; +} + +/// loop_filter_params (6.2.8). +fn loop_filter_params(r: &mut BitReader, lf: &mut LoopFilterParams) -> Result<()> { + lf.level = r.read_bits(6)? as u8; + lf.sharpness = r.read_bits(3)? as u8; + lf.delta_enabled = r.read_flag()?; + if lf.delta_enabled && r.read_flag()? { + for i in 0..4 { + if r.read_flag()? { + lf.ref_deltas[i] = read_s(r, 6)? as i8; + } + } + for i in 0..2 { + if r.read_flag()? { + lf.mode_deltas[i] = read_s(r, 6)? as i8; + } + } + } + Ok(()) +} + +/// segmentation_params (6.2.11). +fn segmentation_params(r: &mut BitReader, seg: &mut Segmentation) -> Result<()> { + seg.enabled = r.read_flag()?; + seg.update_map = false; + seg.temporal_update = false; + if seg.enabled { + seg.update_map = r.read_flag()?; + if seg.update_map { + for i in 0..7 { + seg.tree_probs[i] = read_prob(r)?; + } + seg.temporal_update = r.read_flag()?; + for i in 0..3 { + seg.pred_probs[i] = if seg.temporal_update { read_prob(r)? } else { 255 }; + } + } + if r.read_flag()? { + seg.abs_or_delta_update = r.read_flag()?; + read_segment_features(r, seg)?; + } + } + Ok(()) +} + +fn read_segment_features(r: &mut BitReader, seg: &mut Segmentation) -> Result<()> { + for i in 0..MAX_SEGMENTS { + for j in 0..4 { + let enabled = r.read_flag()?; + seg.feature_enabled[i][j] = enabled; + let mut value = 0i32; + if enabled { + value = r.read_bits(SEGMENTATION_FEATURE_BITS[j])? as i32; + if SEGMENTATION_FEATURE_SIGNED[j] && r.read_flag()? { + value = -value; + } + } + seg.feature_data[i][j] = value as i16; + } + } + Ok(()) +} + +/// tile_info (6.2.13). +fn tile_info(r: &mut BitReader, h: &mut FrameHeader) -> Result<()> { + let mut min_log2 = 0; + while (MAX_TILE_WIDTH_B64 << min_log2) < h.sb64_cols { + min_log2 += 1; + } + let mut max_log2 = 1; + while (h.sb64_cols >> max_log2) >= MIN_TILE_WIDTH_B64 { + max_log2 += 1; + } + max_log2 -= 1; + h.tile_cols_log2 = min_log2; + while h.tile_cols_log2 < max_log2 && r.read_flag()? { + h.tile_cols_log2 += 1; + } + h.tile_rows_log2 = r.read_bits(1)?; + if h.tile_rows_log2 == 1 { + h.tile_rows_log2 += r.read_bits(1)?; + } + h.header_size_in_bytes = r.read_bits(16)?; + Ok(()) +} + /// Parse the uncompressed header (6.2). `refs` gives the sizes of the eight reference slots. /// Updates the persistent state (probability contexts on reset, loop filter deltas, /// segmentation, colour configuration). @@ -255,44 +405,9 @@ pub fn parse_uncompressed(data: &[u8], st: &mut HeaderState, refs: &[Option 0 { - color_config(&mut r, h.profile)? - } else { - ColorConfig { bit_depth: 8, color_space: 1, color_range: false, subsampling_x: true, subsampling_y: true } - }; - h.refresh_frame_flags = r.read_bits(8)? as u8; - frame_size(&mut r, &mut h)?; - render_size(&mut r, &mut h)?; + parse_intra_only_header(&mut r, &mut h)?; } else { - h.color = st.color; - h.refresh_frame_flags = r.read_bits(8)? as u8; - for i in 0..3 { - h.ref_frame_idx[i] = r.read_bits(3)? as u8; - h.ref_frame_sign_bias[LAST_FRAME as usize + i] = r.read_flag()?; - } - // frame_size_with_refs (6.2.5) - let mut found = false; - for i in 0..3 { - if r.read_flag()? { - let idx = h.ref_frame_idx[i] as usize; - let Some(info) = refs[idx] else { - return Err(Error::MissingReference(format!("frame size from empty slot {idx}"))); - }; - h.width = info.width; - h.height = info.height; - found = true; - break; - } - } - if !found { - frame_size(&mut r, &mut h)?; - } - h.size_from_ref = found; - render_size(&mut r, &mut h)?; - h.allow_high_precision_mv = r.read_flag()?; - // read_interpolation_filter (6.2.7) - h.interp_filter = if r.read_flag()? { SWITCHABLE } else { LITERAL_TO_TYPE[r.read_bits(2)? as usize] }; + parse_inter_header(&mut r, &mut h, st.color, refs)?; } } compute_image_size(&mut h); @@ -305,96 +420,17 @@ pub fn parse_uncompressed(data: &[u8], st: &mut HeaderState, refs: &[Option> max_log2) >= MIN_TILE_WIDTH_B64 { - max_log2 += 1; - } - max_log2 -= 1; - h.tile_cols_log2 = min_log2; - while h.tile_cols_log2 < max_log2 { - if r.read_flag()? { - h.tile_cols_log2 += 1; - } else { - break; - } - } - h.tile_rows_log2 = r.read_bits(1)?; - if h.tile_rows_log2 == 1 { - h.tile_rows_log2 += r.read_bits(1)?; - } - h.header_size_in_bytes = r.read_bits(16)?; + segmentation_params(&mut r, &mut st.seg)?; + tile_info(&mut r, &mut h)?; // trailing_bits h.uncompressed_size = r.position().div_ceil(8); if h.frame_is_intra { @@ -458,7 +494,19 @@ fn update_mv_prob(bd: &mut BoolDecoder, prob: &mut u8) { /// Parse the compressed header (6.3) into `h` and the probabilities `fc`. pub fn parse_compressed(data: &[u8], h: &mut FrameHeader, fc: &mut FrameContext) -> Result<()> { let mut bd = BoolDecoder::new(data); - // read_tx_mode (6.3.1) + read_tx_mode(&mut bd, h, fc); + read_coef_probs(&mut bd, h, fc); + for i in 0..3 { + diff_update_prob(&mut bd, &mut fc.skip[i]); + } + if !h.frame_is_intra { + read_inter_probs(&mut bd, h, fc); + } + Ok(()) +} + +/// read_tx_mode (6.3.1) and tx_mode_probs (6.3.5). +fn read_tx_mode(bd: &mut BoolDecoder, h: &mut FrameHeader, fc: &mut FrameContext) { h.tx_mode = if h.lossless { 0 } else { @@ -470,20 +518,23 @@ pub fn parse_compressed(data: &[u8], h: &mut FrameHeader, fc: &mut FrameContext) }; if h.tx_mode == TX_MODE_SELECT { for i in 0..2 { - diff_update_prob(&mut bd, &mut fc.tx[1][i][0]); + diff_update_prob(bd, &mut fc.tx[1][i][0]); } for i in 0..2 { for j in 0..2 { - diff_update_prob(&mut bd, &mut fc.tx[2][i][j]); + diff_update_prob(bd, &mut fc.tx[2][i][j]); } } for i in 0..2 { for j in 0..3 { - diff_update_prob(&mut bd, &mut fc.tx[3][i][j]); + diff_update_prob(bd, &mut fc.tx[3][i][j]); } } } - // read_coef_probs (6.3.7) +} + +/// read_coef_probs (6.3.7). +fn read_coef_probs(bd: &mut BoolDecoder, h: &FrameHeader, fc: &mut FrameContext) { let max_tx = TX_MODE_TO_BIGGEST_TX_SIZE[h.tx_mode as usize] as usize; for tx in 0..=max_tx { if bd.read_literal(1) == 1 { @@ -493,7 +544,7 @@ pub fn parse_compressed(data: &[u8], h: &mut FrameHeader, fc: &mut FrameContext) let max_l = if k == 0 { 3 } else { 6 }; for l in 0..max_l { for m in 0..3 { - diff_update_prob(&mut bd, &mut fc.coef[tx][i][j][k][l][m]); + diff_update_prob(bd, &mut fc.coef[tx][i][j][k][l][m]); } } } @@ -501,104 +552,114 @@ pub fn parse_compressed(data: &[u8], h: &mut FrameHeader, fc: &mut FrameContext) } } } - for i in 0..3 { - diff_update_prob(&mut bd, &mut fc.skip[i]); - } - if !h.frame_is_intra { - for i in 0..7 { - for j in 0..3 { - diff_update_prob(&mut bd, &mut fc.inter_mode[i][j]); - } - } - if h.interp_filter == SWITCHABLE { - for j in 0..4 { - for i in 0..2 { - diff_update_prob(&mut bd, &mut fc.interp_filter[j][i]); - } - } - } - for i in 0..4 { - diff_update_prob(&mut bd, &mut fc.is_inter[i]); - } - // frame_reference_mode (6.3.12) - let bias = h.ref_frame_sign_bias; - let compound_allowed = bias[2] != bias[1] || bias[3] != bias[1]; - h.reference_mode = 0; - if compound_allowed { - if bd.read_literal(1) == 1 { - h.reference_mode = if bd.read_literal(1) == 1 { 2 } else { 1 }; - } - // setup_compound_reference_mode (6.3.18) - if bias[LAST_FRAME as usize] == bias[GOLDEN_FRAME as usize] { - h.comp_fixed_ref = ALTREF_FRAME; - h.comp_var_ref = [LAST_FRAME, GOLDEN_FRAME]; - } else if bias[LAST_FRAME as usize] == bias[ALTREF_FRAME as usize] { - h.comp_fixed_ref = GOLDEN_FRAME; - h.comp_var_ref = [LAST_FRAME, ALTREF_FRAME]; - } else { - h.comp_fixed_ref = LAST_FRAME; - h.comp_var_ref = [GOLDEN_FRAME, ALTREF_FRAME]; - } - } - // frame_reference_mode_probs (6.3.13) - if h.reference_mode == 2 { - for i in 0..5 { - diff_update_prob(&mut bd, &mut fc.comp_mode[i]); - } - } - if h.reference_mode != 1 { - for i in 0..5 { - diff_update_prob(&mut bd, &mut fc.single_ref[i][0]); - diff_update_prob(&mut bd, &mut fc.single_ref[i][1]); - } - } - if h.reference_mode != 0 { - for i in 0..5 { - diff_update_prob(&mut bd, &mut fc.comp_ref[i]); - } - } - for i in 0..4 { - for j in 0..9 { - diff_update_prob(&mut bd, &mut fc.y_mode[i][j]); - } - } - for i in 0..16 { - for j in 0..3 { - diff_update_prob(&mut bd, &mut fc.partition[i][j]); - } - } - // mv_probs (6.3.16) +} + +/// Probability updates that only inter frames carry (6.3.8 to 6.3.16). +fn read_inter_probs(bd: &mut BoolDecoder, h: &mut FrameHeader, fc: &mut FrameContext) { + for i in 0..7 { for j in 0..3 { - update_mv_prob(&mut bd, &mut fc.mv_joint[j]); + diff_update_prob(bd, &mut fc.inter_mode[i][j]); } - for i in 0..2 { - update_mv_prob(&mut bd, &mut fc.mv_sign[i]); - for j in 0..10 { - update_mv_prob(&mut bd, &mut fc.mv_class[i][j]); - } - update_mv_prob(&mut bd, &mut fc.mv_class0_bit[i]); - for j in 0..10 { - update_mv_prob(&mut bd, &mut fc.mv_bits[i][j]); - } - } - for i in 0..2 { - for j in 0..2 { - for k in 0..3 { - update_mv_prob(&mut bd, &mut fc.mv_class0_fr[i][j][k]); - } - } - for k in 0..3 { - update_mv_prob(&mut bd, &mut fc.mv_fr[i][k]); - } - } - if h.allow_high_precision_mv { + } + if h.interp_filter == SWITCHABLE { + for j in 0..4 { for i in 0..2 { - update_mv_prob(&mut bd, &mut fc.mv_class0_hp[i]); - update_mv_prob(&mut bd, &mut fc.mv_hp[i]); + diff_update_prob(bd, &mut fc.interp_filter[j][i]); } } } - Ok(()) + for i in 0..4 { + diff_update_prob(bd, &mut fc.is_inter[i]); + } + read_reference_mode(bd, h); + read_reference_mode_probs(bd, h.reference_mode, fc); + for i in 0..4 { + for j in 0..9 { + diff_update_prob(bd, &mut fc.y_mode[i][j]); + } + } + for i in 0..16 { + for j in 0..3 { + diff_update_prob(bd, &mut fc.partition[i][j]); + } + } + read_mv_probs(bd, h.allow_high_precision_mv, fc); +} + +/// frame_reference_mode (6.3.12) with setup_compound_reference_mode (6.3.18). +fn read_reference_mode(bd: &mut BoolDecoder, h: &mut FrameHeader) { + let bias = h.ref_frame_sign_bias; + let compound_allowed = bias[2] != bias[1] || bias[3] != bias[1]; + h.reference_mode = 0; + if !compound_allowed { + return; + } + if bd.read_literal(1) == 1 { + h.reference_mode = if bd.read_literal(1) == 1 { 2 } else { 1 }; + } + if bias[LAST_FRAME as usize] == bias[GOLDEN_FRAME as usize] { + h.comp_fixed_ref = ALTREF_FRAME; + h.comp_var_ref = [LAST_FRAME, GOLDEN_FRAME]; + } else if bias[LAST_FRAME as usize] == bias[ALTREF_FRAME as usize] { + h.comp_fixed_ref = GOLDEN_FRAME; + h.comp_var_ref = [LAST_FRAME, ALTREF_FRAME]; + } else { + h.comp_fixed_ref = LAST_FRAME; + h.comp_var_ref = [GOLDEN_FRAME, ALTREF_FRAME]; + } +} + +/// frame_reference_mode_probs (6.3.13). +fn read_reference_mode_probs(bd: &mut BoolDecoder, reference_mode: u8, fc: &mut FrameContext) { + if reference_mode == 2 { + for i in 0..5 { + diff_update_prob(bd, &mut fc.comp_mode[i]); + } + } + if reference_mode != 1 { + for i in 0..5 { + diff_update_prob(bd, &mut fc.single_ref[i][0]); + diff_update_prob(bd, &mut fc.single_ref[i][1]); + } + } + if reference_mode != 0 { + for i in 0..5 { + diff_update_prob(bd, &mut fc.comp_ref[i]); + } + } +} + +/// mv_probs (6.3.16). +fn read_mv_probs(bd: &mut BoolDecoder, allow_high_precision_mv: bool, fc: &mut FrameContext) { + for j in 0..3 { + update_mv_prob(bd, &mut fc.mv_joint[j]); + } + for i in 0..2 { + update_mv_prob(bd, &mut fc.mv_sign[i]); + for j in 0..10 { + update_mv_prob(bd, &mut fc.mv_class[i][j]); + } + update_mv_prob(bd, &mut fc.mv_class0_bit[i]); + for j in 0..10 { + update_mv_prob(bd, &mut fc.mv_bits[i][j]); + } + } + for i in 0..2 { + for j in 0..2 { + for k in 0..3 { + update_mv_prob(bd, &mut fc.mv_class0_fr[i][j][k]); + } + } + for k in 0..3 { + update_mv_prob(bd, &mut fc.mv_fr[i][k]); + } + } + if allow_high_precision_mv { + for i in 0..2 { + update_mv_prob(bd, &mut fc.mv_class0_hp[i]); + update_mv_prob(bd, &mut fc.mv_hp[i]); + } + } } /// Superframe index parsing (Annex B): the frames of a chunk. diff --git a/crates/vp9/src/intra.rs b/crates/vp9/src/intra.rs index 642a28b1..a8d7d396 100644 --- a/crates/vp9/src/intra.rs +++ b/crates/vp9/src/intra.rs @@ -14,11 +14,30 @@ pub struct IntraEdge { pub bit_depth: u8, } -/// Predict a `4 << tx_size` square block at (`x`, `y`) of a plane buffer. `buf` is addressed as -/// `buf[y * stride + x - x_off]` (the buffer may be a tile-column strip starting at column `x_off`). -pub fn predict(buf: &mut [u16], stride: usize, x_off: usize, x: usize, y: usize, mode: u8, e: &IntraEdge) { - let log2 = e.tx_size as usize + 2; - let size = 1usize << log2; +/// Above row (index 0 is the top-left corner, then `2 * size` samples) and left column of a block. +struct Edges { + above: [i32; 65], + left: [i32; 32], +} + +impl Edges { + /// `aboveRow[i]` for `i >= -1`. + fn a(&self, i: isize) -> i32 { + self.above[(i + 1) as usize] + } +} + +fn r1(v: i32) -> i32 { + (v + 1) >> 1 +} + +fn r2(v: i32) -> i32 { + (v + 2) >> 2 +} + +/// Gathers the edge samples of the block at (`x`, `y`), substituting the spec's defaults where unavailable. +fn load_edges(buf: &[u16], stride: usize, x_off: usize, x: usize, y: usize, e: &IntraEdge) -> Edges { + let size = 1usize << (e.tx_size as usize + 2); let base = 1i32 << (e.bit_depth - 1); let at = |xx: usize, yy: usize| yy * stride + xx - x_off; // aboveRow[-1..2*size) stored at above[0..2*size+1); leftCol[0..size). @@ -54,161 +73,149 @@ pub fn predict(buf: &mut [u16], stride: usize, x_off: usize, x: usize, y: usize, } else { left[..size].fill(base + 1); } - let a = |i: isize| above[(i + 1) as usize]; - let max = (1i32 << e.bit_depth) - 1; - let o = at(x, y); + Edges { above, left } +} + +/// Predict a `4 << tx_size` square block at (`x`, `y`) of a plane buffer. `buf` is addressed as +/// `buf[y * stride + x - x_off]` (the buffer may be a tile-column strip starting at column `x_off`). +pub fn predict(buf: &mut [u16], stride: usize, x_off: usize, x: usize, y: usize, mode: u8, e: &IntraEdge) { + let size = 1usize << (e.tx_size as usize + 2); + let edges = load_edges(buf, stride, x_off, x, y, e); + let o = y * stride + x - x_off; let mut put = |i: usize, j: usize, v: i32| buf[o + i * stride + j] = v as u16; - let r1 = |v: i32| (v + 1) >> 1; - let r2 = |v: i32| (v + 2) >> 2; match mode { - V_PRED => { - for i in 0..size { - for j in 0..size { - put(i, j, a(j as isize)); - } - } - } - H_PRED => { - for i in 0..size { - for j in 0..size { - put(i, j, left[i]); - } - } - } - D207_PRED => { - let mut p = [[0i32; 32]; 32]; - for j in 0..size { - p[size - 1][j] = left[size - 1]; - } - for i in 0..size - 1 { - p[i][0] = r1(left[i] + left[i + 1]); - } - for i in 0..size.saturating_sub(2) { - p[i][1] = r2(left[i] + 2 * left[i + 1] + left[i + 2]); - } - p[size - 2][1] = r2(left[size - 2] + 3 * left[size - 1]); - for j in 2..size { - for i in (0..size - 1).rev() { - p[i][j] = p[i + 1][j - 2]; - } - } - for i in 0..size { - for j in 0..size { - put(i, j, p[i][j]); - } - } - } - D45_PRED => { - for i in 0..size { - for j in 0..size { - let k = (i + j) as isize; - let v = if i + j + 2 < 2 * size { r2(a(k) + 2 * a(k + 1) + a(k + 2)) } else { a(2 * size as isize - 1) }; - put(i, j, v); - } - } - } - D63_PRED => { - for i in 0..size { - for j in 0..size { - let k = (i / 2 + j) as isize; - let v = if i & 1 == 1 { r2(a(k) + 2 * a(k + 1) + a(k + 2)) } else { r1(a(k) + a(k + 1)) }; - put(i, j, v); - } - } - } - D117_PRED => { - let mut p = [[0i32; 32]; 32]; - for j in 0..size { - p[0][j] = r1(a(j as isize - 1) + a(j as isize)); - } - p[1][0] = r2(left[0] + 2 * a(-1) + a(0)); - for j in 1..size { - p[1][j] = r2(a(j as isize - 2) + 2 * a(j as isize - 1) + a(j as isize)); - } - p[2][0] = r2(a(-1) + 2 * left[0] + left[1]); - for i in 3..size { - p[i][0] = r2(left[i - 3] + 2 * left[i - 2] + left[i - 1]); - } - for i in 2..size { - for j in 1..size { - p[i][j] = p[i - 2][j - 1]; - } - } - for i in 0..size { - for j in 0..size { - put(i, j, p[i][j]); - } - } - } - D135_PRED => { - let mut p = [[0i32; 32]; 32]; - p[0][0] = r2(left[0] + 2 * a(-1) + a(0)); - for j in 1..size { - p[0][j] = r2(a(j as isize - 2) + 2 * a(j as isize - 1) + a(j as isize)); - } - p[1][0] = r2(a(-1) + 2 * left[0] + left[1]); - for i in 2..size { - p[i][0] = r2(left[i - 2] + 2 * left[i - 1] + left[i]); - } - for i in 1..size { - for j in 1..size { - p[i][j] = p[i - 1][j - 1]; - } - } - for i in 0..size { - for j in 0..size { - put(i, j, p[i][j]); - } - } - } - D153_PRED => { - let mut p = [[0i32; 32]; 32]; - p[0][0] = r1(left[0] + a(-1)); - for i in 1..size { - p[i][0] = r1(left[i - 1] + left[i]); - } - p[0][1] = r2(left[0] + 2 * a(-1) + a(0)); - p[1][1] = r2(a(-1) + 2 * left[0] + left[1]); - for i in 2..size { - p[i][1] = r2(left[i - 2] + 2 * left[i - 1] + left[i]); - } - for j in 2..size { - p[0][j] = r2(a(j as isize - 3) + 2 * a(j as isize - 2) + a(j as isize - 1)); - } - for i in 1..size { - for j in 2..size { - p[i][j] = p[i - 1][j - 2]; - } - } - for i in 0..size { - for j in 0..size { - put(i, j, p[i][j]); - } - } - } + V_PRED => fill_with(size, &mut put, |_, j| edges.a(j as isize)), + H_PRED => fill_with(size, &mut put, |i, _| edges.left[i]), + D207_PRED => predict_d207(size, &edges, &mut put), + D45_PRED => fill_with(size, &mut put, |i, j| { + let k = (i + j) as isize; + if i + j + 2 < 2 * size { r2(edges.a(k) + 2 * edges.a(k + 1) + edges.a(k + 2)) } else { edges.a(2 * size as isize - 1) } + }), + D63_PRED => fill_with(size, &mut put, |i, j| { + let k = (i / 2 + j) as isize; + if i & 1 == 1 { r2(edges.a(k) + 2 * edges.a(k + 1) + edges.a(k + 2)) } else { r1(edges.a(k) + edges.a(k + 1)) } + }), + D117_PRED => predict_d117(size, &edges, &mut put), + D135_PRED => predict_d135(size, &edges, &mut put), + D153_PRED => predict_d153(size, &edges, &mut put), TM_PRED => { - for i in 0..size { - for j in 0..size { - put(i, j, (a(j as isize) + left[i] - a(-1)).clamp(0, max)); - } - } + let max = (1i32 << e.bit_depth) - 1; + fill_with(size, &mut put, |i, j| (edges.a(j as isize) + edges.left[i] - edges.a(-1)).clamp(0, max)); } _ => { - // DC_PRED - let v = match (e.have_left, e.have_above) { - (true, true) => { - let sum: i32 = left[..size].iter().sum::() + above[1..=size].iter().sum::(); - (sum + size as i32) >> (log2 + 1) - } - (true, false) => (left[..size].iter().sum::() + (1 << (log2 - 1))) >> log2, - (false, true) => (above[1..=size].iter().sum::() + (1 << (log2 - 1))) >> log2, - (false, false) => base, - }; - for i in 0..size { - for j in 0..size { - put(i, j, v); - } - } + let v = dc_value(size, &edges, e); + fill_with(size, &mut put, |_, _| v); } } } + +/// Writes `value(i, j)` to every sample of a `size` square block. +fn fill_with(size: usize, put: &mut impl FnMut(usize, usize, i32), value: impl Fn(usize, usize) -> i32) { + for i in 0..size { + for j in 0..size { + put(i, j, value(i, j)); + } + } +} + +/// Writes a computed prediction table to the block. +fn put_table(size: usize, p: &[[i32; 32]; 32], put: &mut impl FnMut(usize, usize, i32)) { + fill_with(size, put, |i, j| p[i][j]); +} + +fn dc_value(size: usize, edges: &Edges, e: &IntraEdge) -> i32 { + let log2 = e.tx_size as usize + 2; + let left: i32 = edges.left[..size].iter().sum(); + let above: i32 = edges.above[1..=size].iter().sum(); + match (e.have_left, e.have_above) { + (true, true) => (left + above + size as i32) >> (log2 + 1), + (true, false) => (left + (1 << (log2 - 1))) >> log2, + (false, true) => (above + (1 << (log2 - 1))) >> log2, + (false, false) => 1i32 << (e.bit_depth - 1), + } +} + +fn predict_d207(size: usize, edges: &Edges, put: &mut impl FnMut(usize, usize, i32)) { + let left = &edges.left; + let mut p = [[0i32; 32]; 32]; + for j in 0..size { + p[size - 1][j] = left[size - 1]; + } + for i in 0..size - 1 { + p[i][0] = r1(left[i] + left[i + 1]); + } + for i in 0..size.saturating_sub(2) { + p[i][1] = r2(left[i] + 2 * left[i + 1] + left[i + 2]); + } + p[size - 2][1] = r2(left[size - 2] + 3 * left[size - 1]); + for j in 2..size { + for i in (0..size - 1).rev() { + p[i][j] = p[i + 1][j - 2]; + } + } + put_table(size, &p, put); +} + +fn predict_d117(size: usize, edges: &Edges, put: &mut impl FnMut(usize, usize, i32)) { + let (left, a) = (&edges.left, |i: isize| edges.a(i)); + let mut p = [[0i32; 32]; 32]; + for j in 0..size { + p[0][j] = r1(a(j as isize - 1) + a(j as isize)); + } + p[1][0] = r2(left[0] + 2 * a(-1) + a(0)); + for j in 1..size { + p[1][j] = r2(a(j as isize - 2) + 2 * a(j as isize - 1) + a(j as isize)); + } + p[2][0] = r2(a(-1) + 2 * left[0] + left[1]); + for i in 3..size { + p[i][0] = r2(left[i - 3] + 2 * left[i - 2] + left[i - 1]); + } + for i in 2..size { + for j in 1..size { + p[i][j] = p[i - 2][j - 1]; + } + } + put_table(size, &p, put); +} + +fn predict_d135(size: usize, edges: &Edges, put: &mut impl FnMut(usize, usize, i32)) { + let (left, a) = (&edges.left, |i: isize| edges.a(i)); + let mut p = [[0i32; 32]; 32]; + p[0][0] = r2(left[0] + 2 * a(-1) + a(0)); + for j in 1..size { + p[0][j] = r2(a(j as isize - 2) + 2 * a(j as isize - 1) + a(j as isize)); + } + p[1][0] = r2(a(-1) + 2 * left[0] + left[1]); + for i in 2..size { + p[i][0] = r2(left[i - 2] + 2 * left[i - 1] + left[i]); + } + for i in 1..size { + for j in 1..size { + p[i][j] = p[i - 1][j - 1]; + } + } + put_table(size, &p, put); +} + +fn predict_d153(size: usize, edges: &Edges, put: &mut impl FnMut(usize, usize, i32)) { + let (left, a) = (&edges.left, |i: isize| edges.a(i)); + let mut p = [[0i32; 32]; 32]; + p[0][0] = r1(left[0] + a(-1)); + for i in 1..size { + p[i][0] = r1(left[i - 1] + left[i]); + } + p[0][1] = r2(left[0] + 2 * a(-1) + a(0)); + p[1][1] = r2(a(-1) + 2 * left[0] + left[1]); + for i in 2..size { + p[i][1] = r2(left[i - 2] + 2 * left[i - 1] + left[i]); + } + for j in 2..size { + p[0][j] = r2(a(j as isize - 3) + 2 * a(j as isize - 2) + a(j as isize - 1)); + } + for i in 1..size { + for j in 2..size { + p[i][j] = p[i - 1][j - 2]; + } + } + put_table(size, &p, put); +} diff --git a/crates/vp9/src/probs.rs b/crates/vp9/src/probs.rs index 70fc888a..936e4494 100644 --- a/crates/vp9/src/probs.rs +++ b/crates/vp9/src/probs.rs @@ -142,22 +142,50 @@ macro_rules! add_nested { add_nested!(x, y, 2); } }; + ($a:expr, $b:expr, 4) => { + for (x, y) in $a.iter_mut().zip($b.iter()) { + add_nested!(x, y, 3); + } + }; + ($a:expr, $b:expr, 5) => { + for (x, y) in $a.iter_mut().zip($b.iter()) { + add_nested!(x, y, 4); + } + }; + ($a:expr, $b:expr, 6) => { + for (x, y) in $a.iter_mut().zip($b.iter()) { + add_nested!(x, y, 5); + } + }; } impl Counts { /// Accumulate the counts of another tile. pub fn add(&mut self, o: &Counts) { + self.add_mode_counts(o); + self.add_ref_counts(o); + self.add_mv_counts(o); + self.add_coef_counts(o); + } + + fn add_mode_counts(&mut self, o: &Counts) { add_nested!(self.intra_mode, o.intra_mode, 2); add_nested!(self.uv_mode, o.uv_mode, 2); add_nested!(self.partition, o.partition, 2); add_nested!(self.interp_filter, o.interp_filter, 2); add_nested!(self.inter_mode, o.inter_mode, 2); add_nested!(self.tx, o.tx, 3); + add_nested!(self.skip, o.skip, 2); + } + + fn add_ref_counts(&mut self, o: &Counts) { add_nested!(self.is_inter, o.is_inter, 2); add_nested!(self.comp_mode, o.comp_mode, 2); add_nested!(self.single_ref, o.single_ref, 3); add_nested!(self.comp_ref, o.comp_ref, 2); - add_nested!(self.skip, o.skip, 2); + } + + fn add_mv_counts(&mut self, o: &Counts) { add_nested!(self.mv_joint, o.mv_joint, 1); add_nested!(self.mv_sign, o.mv_sign, 2); add_nested!(self.mv_class, o.mv_class, 2); @@ -167,24 +195,11 @@ impl Counts { add_nested!(self.mv_bits, o.mv_bits, 3); add_nested!(self.mv_fr, o.mv_fr, 2); add_nested!(self.mv_hp, o.mv_hp, 2); - for (a, b) in self.token.iter_mut().zip(o.token.iter()) { - for (a, b) in a.iter_mut().zip(b.iter()) { - for (a, b) in a.iter_mut().zip(b.iter()) { - for (a, b) in a.iter_mut().zip(b.iter()) { - add_nested!(a, b, 2); - } - } - } - } - for (a, b) in self.more_coefs.iter_mut().zip(o.more_coefs.iter()) { - for (a, b) in a.iter_mut().zip(b.iter()) { - for (a, b) in a.iter_mut().zip(b.iter()) { - for (a, b) in a.iter_mut().zip(b.iter()) { - add_nested!(a, b, 2); - } - } - } - } + } + + fn add_coef_counts(&mut self, o: &Counts) { + add_nested!(self.token, o.token, 6); + add_nested!(self.more_coefs, o.more_coefs, 6); } } diff --git a/crates/vp9/src/tile/recon.rs b/crates/vp9/src/tile/recon.rs index a38da45c..00450cd5 100644 --- a/crates/vp9/src/tile/recon.rs +++ b/crates/vp9/src/tile/recon.rs @@ -146,16 +146,8 @@ impl<'a> TileDecoder<'a> { let max_y4 = (2 * self.mi_rows) >> sy; let n = 1usize << tx_sz; let (x4, y4) = (start_x >> 2, start_y >> 2); - let mut above = 0u8; - let mut left = 0u8; - for i in 0..n { - if x4 + i < max_x4 { - above |= self.above_nonzero[plane][x4 + i]; - } - if y4 + i < max_y4 { - left |= self.left_nonzero[plane][y4 + i]; - } - } + let above = nonzero_span(&self.above_nonzero[plane], x4, n, max_x4); + let left = nonzero_span(&self.left_nonzero[plane], y4, n, max_y4); let ctx0 = (above + left) as usize; let q = self.s.seg_q[b.seg_id as usize][ptype]; let dq_shift = (tx_sz == TX_32X32) as u32; @@ -211,40 +203,8 @@ impl<'a> TileDecoder<'a> { counts.token[tx_sz as usize][ptype][rtype][band][ctx][2] += 1; } let pp = &pareto[p[2].max(1) as usize - 1]; - token = if !bd.read_bool(pp[0]) { - if !bd.read_bool(pp[1]) { - 2 - } else if !bd.read_bool(pp[2]) { - 3 - } else { - 4 - } - } else if !bd.read_bool(pp[3]) { - if !bd.read_bool(pp[4]) { 5 } else { 6 } - } else if !bd.read_bool(pp[5]) { - if !bd.read_bool(pp[6]) { 7 } else { 8 } - } else if !bd.read_bool(pp[7]) { - 9 - } else { - 10 - }; - // read_coef (6.4.26) - let (cat, num_extra, base) = EXTRA_BITS[token]; - let mut v = base as i64; - if token == 10 { - for e in 0..(bit_depth as u32 - 8) { - if bd.read_bool(255) { - v += 1 << (5 + bit_depth as u32 - e); - } - } - } - let cp = CAT_PROBS[cat as usize]; - for e in 0..num_extra as usize { - if bd.read_bool(cp[e]) { - v += 1 << (num_extra as usize - 1 - e); - } - } - coef = v; + token = read_large_token(bd, pp); + coef = read_coef(bd, token, bit_depth); } tc[pos] = ENERGY_CLASS[token]; let sign = bd.read_bool(128); @@ -346,3 +306,49 @@ impl<'a> TileDecoder<'a> { } } } + +/// OR of the non-zero flags of `n` entries starting at `start`, ignoring entries at or past `limit`. +fn nonzero_span(flags: &[u8], start: usize, n: usize, limit: usize) -> u8 { + (start..start + n).filter(|&i| i < limit).fold(0, |acc, i| acc | flags[i]) +} + +/// Reads the token tree below the "more than one" node (tokens 2 to 10). +fn read_large_token(bd: &mut BoolDecoder, pp: &[u8; 8]) -> usize { + if !bd.read_bool(pp[0]) { + if !bd.read_bool(pp[1]) { + 2 + } else if !bd.read_bool(pp[2]) { + 3 + } else { + 4 + } + } else if !bd.read_bool(pp[3]) { + if !bd.read_bool(pp[4]) { 5 } else { 6 } + } else if !bd.read_bool(pp[5]) { + if !bd.read_bool(pp[6]) { 7 } else { 8 } + } else if !bd.read_bool(pp[7]) { + 9 + } else { + 10 + } +} + +/// read_coef (6.4.26): the coefficient magnitude for `token`, including its extra bits. +fn read_coef(bd: &mut BoolDecoder, token: usize, bit_depth: u8) -> i64 { + let (cat, num_extra, base) = EXTRA_BITS[token]; + let mut v = base as i64; + if token == 10 { + for e in 0..(bit_depth as u32 - 8) { + if bd.read_bool(255) { + v += 1 << (5 + bit_depth as u32 - e); + } + } + } + let cp = CAT_PROBS[cat as usize]; + for e in 0..num_extra as usize { + if bd.read_bool(cp[e]) { + v += 1 << (num_extra as usize - 1 - e); + } + } + v +} diff --git a/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml index 6245147c..230c2289 100644 --- a/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml @@ -31,9 +31,9 @@ finish-args: # document portal, so picked files work anywhere without broad filesystem access. Documents is # granted so decks passed by path (the command line, Open Recent) work in the usual place. - --filesystem=xdg-documents - # Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in - # the sandbox; `flatpak override --user --share=network ai.storyteller.deckcraft` exposes it to - # host agents) and home/host filesystem access. + # Network: update checks reach GitHub. The optional --control server still listens on 127.0.0.1 + # only. Home/host filesystem access is deliberately not granted. + - --share=network modules: - name: deckcraft buildsystem: simple diff --git a/packaging/linux/flatpak/ai.storyteller.deckcraft.yml b/packaging/linux/flatpak/ai.storyteller.deckcraft.yml index 574dbc86..b99365c2 100644 --- a/packaging/linux/flatpak/ai.storyteller.deckcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.deckcraft.yml @@ -32,9 +32,9 @@ finish-args: # document portal, so picked files work anywhere without broad filesystem access. Documents is # granted so decks passed by path (the command line, Open Recent) work in the usual place. - --filesystem=xdg-documents - # Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in - # the sandbox; `flatpak override --user --share=network ai.storyteller.deckcraft` exposes it to - # host agents) and home/host filesystem access. + # Network: update checks reach GitHub. The optional --control server still listens on 127.0.0.1 + # only. Home/host filesystem access is deliberately not granted. + - --share=network build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index ef7f99c5..2fba0d14 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -80,6 +80,21 @@ sign() { codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } +# `stapler staple` and `stapler validate` both fetch the ticket from Apple's CloudKit, and a dropped +# connection there (NSURLErrorDomain -1005, "Error 68") fails them although notarization worked. +# Both are idempotent, so retry with a pause; a missing or bad ticket still fails every try. +stapler_retry() { + local try + for try in 1 2 3 4; do + xcrun stapler "$1" "$2" && return 0 + [[ "$try" = 4 ]] && break + echo "stapler $1 failed (try $try of 4); retrying in $((try * 20)) s" >&2 + sleep $((try * 20)) + done + echo "error: stapler $1 $(basename "$2") failed 4 times" >&2 + exit 1 +} + notarize() { local file="$1" out id status echo "==> notarizing $(basename "$file") (this can take a few minutes)" @@ -121,8 +136,8 @@ codesign --verify --strict --deep --verbose=2 "$APP" if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/DeckCraft-notarize.zip" notarize "$WORK/DeckCraft-notarize.zip" - xcrun stapler staple "$APP" - xcrun stapler validate "$APP" + stapler_retry staple "$APP" + stapler_retry validate "$APP" spctl --assess --type execute -vvv "$APP" fi @@ -147,8 +162,8 @@ sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" + stapler_retry staple "$DMG" + stapler_retry validate "$DMG" spctl --assess --type open --context context:primary-signature -vvv "$DMG" fi diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 00000000..982a5b42 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 00000000..cc9416e9 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,296 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' + } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/deckcraft.wxs b/packaging/windows/deckcraft.wxs index 3d429ea7..f6efd99f 100644 --- a/packaging/windows/deckcraft.wxs +++ b/packaging/windows/deckcraft.wxs @@ -1,32 +1,14 @@ - - + - - + + + - + @@ -36,16 +18,23 @@ - + + + - + + + + + + @@ -53,7 +42,7 @@ - + @@ -65,8 +54,7 @@ pointing at the deleted file and lost its icon (#1114). The exe's path survives updates; the shortcut is not advertised, so it targets that path and its icon is the exe's own. The Icon above stays for ARPPRODUCTICON (Apps and features). --> - + @@ -77,17 +65,18 @@ - - - - - - - - - + + + + + + + + + - + + diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 00000000..e13f06bd --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,81 @@ +{ + "id": "deckcraft", + "name": "DeckCraft", + "upgradeCode": "6F9C69E7-7825-40D2-BF26-C274DA676425", + "sources": [ + "deckcraft.wxs" + ], + "icon": "assets/app-icon/deckcraft.ico", + "localization": [], + "extensions": [ + "UI" + ], + "payloads": [ + "NOTICE", + "deckcraft-cli.exe", + "deckcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Classes\\Applications\\deckcraft.exe", + "FriendlyAppName", + "DeckCraft" + ], + [ + "-1", + "Software\\Classes\\Applications\\deckcraft.exe\\shell\\open\\command", + "", + "\"[#DeckcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\Applications\\deckcraft.exe\\SupportedTypes", + ".deckcraft", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\deckcraft.exe\\SupportedTypes", + ".pptx", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\deckcraft.exe\\SupportedTypes", + ".potx", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\deckcraft.exe\\SupportedTypes", + ".ppsx", + "" + ], + [ + "-1", + "Software\\Classes\\.pptx\\OpenWithProgids", + "DeckCraft.Presentation", + "" + ], + [ + "-1", + "Software\\Classes\\.potx\\OpenWithProgids", + "DeckCraft.Presentation", + "" + ], + [ + "-1", + "Software\\Classes\\.ppsx\\OpenWithProgids", + "DeckCraft.Presentation", + "" + ], + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\deckcraft.exe", + "", + "[#DeckcraftExe]" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 00000000..355c12d1 --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 6023a722..b2a5825b 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -116,12 +116,15 @@ if ($env:CRAFT_FONTS_DIR) { # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "deckcraft-$Version-windows-$Arch.msi" +Invoke-Native 'WiX UI extension' { wix extension add -g WixToolset.UI.wixext/5.0.2 } Invoke-Native 'wix build' { - wix build (Join-Path $PSScriptRoot 'deckcraft.wxs') -arch $Arch ` + wix build (Join-Path $PSScriptRoot 'deckcraft.wxs') -arch $Arch -ext WixToolset.UI.wixext ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\deckcraft.ico')" ` -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 00000000..d455d19d --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index df7dfa87..6d935ae8 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -5,5 +5,8 @@ edition.workspace = true license.workspace = true publish = false +[lints] +workspace = true + [dependencies] serde_json = { workspace = true } diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs index d25bb0f4..100aa3a0 100644 --- a/xtask/src/pdf_deps.rs +++ b/xtask/src/pdf_deps.rs @@ -6,7 +6,7 @@ use std::process::Command; use serde_json::Value; -const SHARED_REV: &str = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"; +const SHARED_REV: &str = "92d392cff132eaf53e13afff77f38b5c11b73aa0"; const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index"; pub fn run(root: &Path) -> Result<(), String> {