Compare commits

...
1 Commits
Author SHA1 Message Date
skylar taylor-barrick 14f40c408c Migrate DeckCraft to egui 0.37 (#1350)
* egui037-20261022: migrate DeckCraft to egui 0.37

Preserve current-main document and PDF behavior alongside the reviewed input, capture, font, ribbon and notice-packaging migration.

Validation: cargo xtask ci passed all eight stages, with 1,877 workspace tests and three doctests passed, six tests ignored, strict native lint and 23 WASM crates checked. Native GUI, platform and performance qualification remains separate.

* E21: restore Latin coverage and enforce required font inputs

* Fix migration review findings in font validation and UI controls

* M-egui037: document UI lifecycle and regression contracts
2026-10-25 10:48:52 -04:00
63 changed files with 11517 additions and 2553 deletions
+34 -1
View File
@@ -17,12 +17,45 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Keep the early manifest preflight separate from the reusable workflow. The shared gate
# also runs with require-craft-fonts: true, so the build script enforces actual required faces.
# Keep this pin identical to gate, freebsd.yml and release.yml.
font-pin:
runs-on: ubuntu-latest
env:
# Same craft-fonts commit as release.yml.
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
steps:
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Font pin gate
env:
# The only pin allowed to predate the reviewed Chinese manifest. It is a sentinel, not
# a proposed future SHA; update it together with all three workflow pins.
PENDING_CRAFT_FONTS_PIN: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
run: |
manifest="$GITHUB_WORKSPACE/craft-fonts/fonts/simplified-chinese-manifest.txt"
if [ -f "$manifest" ]; then
echo "craft-fonts pin carries the reviewed simplified-Chinese manifest."
elif [ "$CRAFT_FONTS_REF" = "$PENDING_CRAFT_FONTS_PIN" ]; then
echo "::warning title=craft-fonts pin pending::The pinned craft-fonts commit has no fonts/simplified-chinese-manifest.txt, so CJK checks may skip. Advance ci.yml, freebsd.yml and release.yml together to the reviewed immutable SHA before claiming shipped Chinese fonts."
else
echo "::error title=craft-fonts pin missing Chinese manifest::CRAFT_FONTS_REF advanced past the known pending pin ($PENDING_CRAFT_FONTS_PIN) but still has no fonts/simplified-chinese-manifest.txt. Update the pin to the reviewed commit that carries it."
exit 1
fi
gate:
needs: font-pin
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
require-craft-fonts: true
packages: >-
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev
libgl1-mesa-dev
+5 -1
View File
@@ -27,4 +27,8 @@ jobs:
# 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/freebsd.yml@main
with:
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Keep identical to ci.yml and release.yml. The shared FreeBSD workflow passes
# CRAFT_FONTS_DIR to its tests but has no CRAFT_FONTS_REQUIRED input yet, so it does not
# qualify the required Chinese input after the pin advances; that gate stays explicitly
# pending in docs/egui-037-migration-plan.md section 5.1.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
+4 -1
View File
@@ -24,7 +24,10 @@ jobs:
with:
app-name: deckcraft
app-title: DeckCraft
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
# CRAFT_FONTS_REQUIRED=1 in its packaging steps, so a pin without the reviewed Chinese
# manifest fails the release instead of silently skipping it.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+20 -4
View File
@@ -41,8 +41,9 @@ artwork. The ribbon and menu icons stay original drawings.
| Asset | Author | Source | Licence | Used for |
|---|---|---|---|---|
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.36) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
| Hack Regular, Noto Emoji, emoji-icon-font (via `epaint_default_fonts` / egui) | Source Foundry; Google; Jakub Steiner et al. | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1, MIT | egui UI fallback glyphs |
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.37) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
| Hack Regular, Noto Emoji (via `epaint_default_fonts` / egui) | Source Foundry; Google | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1 | egui UI fallback glyphs |
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
## Embedded at build time from craft-fonts (`CRAFT_FONTS_DIR`)
@@ -58,9 +59,14 @@ families excluded by `crates/fonts/build.rs`:
| Carlito, Caladea | OFL-1.1 | Metric-compatible stand-ins for documents that ask for Calibri / Cambria |
| Liberation Sans, Serif, Mono | OFL-1.1 | Metric-compatible stand-ins for Arial, Times New Roman, Courier New |
| Montserrat, Lato, Open Sans, Roboto, Merriweather, Playfair Display, Poppins, Nunito Sans | OFL-1.1 | Theme fonts |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho | OFL-1.1 | Japanese text |
| Noto Sans Arabic, Noto Sans Hebrew | OFL-1.1 | Arabic and Hebrew text |
| Noto Sans Thai, Noto Sans Georgian | OFL-1.1 | Thai and Georgian text |
| NanumGothic | OFL-1.1 | Korean text and interface fallback |
| Noto Sans Symbols, Noto Sans Symbols 2 | OFL-1.1 | Interface symbol fallback |
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
Web (wasm32) builds embed only Inter Regular/Bold (and a Variable face where the Latin manifest
provides one) and BIZ UDPGothic Regular.
## PDF writer colour profiles
@@ -75,3 +81,13 @@ is copied into this repository. The licence text is in `licenses/pdf-writer/LICE
| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles |
### Optional native Chinese fallback
Native builds also read craft-fonts' `fonts/simplified-chinese-manifest.txt` when present.
This opt-in does not enter the wasm font allowlist. Font bytes and the full licence remain
in craft-fonts, including the LXGW Reserved Font Names and original Klee notice.
| Family | Author | Source | Licence | Use |
|---|---|---|---|---|
| LXGW WenKai Lite Regular 1.530 | LXGW; original Klee Project Authors / Fontworks | https://github.com/lxgw/LxgwWenKai-Lite/tree/b712c83d98acf977bacfb7fb010207802808bda9 | OFL-1.1, unmodified upstream font | Native Chinese glyph fallback after existing UI faces; font SHA256 `f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea`, OFL SHA256 `0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e` |
Generated
+191 -226
View File
@@ -4,21 +4,21 @@ version = 4
[[package]]
name = "accesskit"
version = "0.24.1"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
dependencies = [
"uuid",
]
[[package]]
name = "accesskit_atspi_common"
version = "0.18.1"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4"
dependencies = [
"accesskit",
"accesskit_consumer 0.36.0",
"accesskit_consumer",
"atspi-common",
"phf",
"serde",
@@ -27,43 +27,37 @@ dependencies = [
[[package]]
name = "accesskit_consumer"
version = "0.35.0"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
name = "accesskit_consumer"
version = "0.36.0"
name = "accesskit_ios"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
]
[[package]]
name = "accesskit_consumer"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"objc2 0.5.3",
"objc2-foundation 0.2.2",
"objc2-ui-kit 0.2.2",
]
[[package]]
name = "accesskit_macos"
version = "0.26.3"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4"
dependencies = [
"accesskit",
"accesskit_consumer 0.38.0",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -71,9 +65,9 @@ dependencies = [
[[package]]
name = "accesskit_unix"
version = "0.21.1"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d"
dependencies = [
"accesskit",
"accesskit_atspi_common",
@@ -89,13 +83,13 @@ dependencies = [
[[package]]
name = "accesskit_windows"
version = "0.32.1"
version = "0.35.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"static_assertions",
"windows 0.62.2",
"windows-core 0.62.2",
@@ -103,11 +97,12 @@ dependencies = [
[[package]]
name = "accesskit_winit"
version = "0.32.2"
version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77"
dependencies = [
"accesskit",
"accesskit_ios",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
@@ -228,6 +223,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -554,42 +550,18 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "calloop"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.5",
"tracing",
]
[[package]]
name = "calloop-wayland-source"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
dependencies = [
"calloop 0.13.0",
"calloop",
"rustix 0.38.44",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "calloop-wayland-source"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop 0.14.5",
"rustix 1.1.5",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "cast"
version = "0.3.0"
@@ -1141,8 +1113,8 @@ dependencies = [
"kurbo",
"log",
"skrifa 0.47.0",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
"vello_common",
"vello_cpu",
]
[[package]]
@@ -1174,11 +1146,12 @@ dependencies = [
"deckcraft-text",
"egui",
"egui_kittest",
"js-sys",
"log",
"serde",
"serde_json",
"skrifa 0.47.0",
"toml",
"web-time",
]
[[package]]
@@ -1292,9 +1265,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "ecolor"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1302,9 +1274,8 @@ dependencies = [
[[package]]
name = "eframe"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1326,6 +1297,7 @@ dependencies = [
"pollster 1.0.1",
"profiling",
"raw-window-handle",
"send_wrapper",
"static_assertions",
"wasm-bindgen",
"web-sys",
@@ -1337,9 +1309,8 @@ dependencies = [
[[package]]
name = "egui"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1357,9 +1328,8 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1377,31 +1347,31 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit_winit",
"arboard",
"block2 0.6.2",
"bytemuck",
"egui",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"profiling",
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
"windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_glow"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1413,9 +1383,8 @@ dependencies = [
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"dify",
"eframe",
@@ -1440,9 +1409,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "emath"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
]
@@ -1490,9 +1458,8 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1508,16 +1475,17 @@ dependencies = [
"self_cell",
"skrifa 0.44.0",
"smallvec",
"unicode-bidi",
"unicode-general-category",
"unicode-properties",
"unicode-segmentation",
"vello_cpu 0.1.0",
"vello_cpu",
]
[[package]]
name = "epaint_default_fonts"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equivalent"
@@ -1609,6 +1577,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -1640,6 +1614,12 @@ dependencies = [
"objc2-foundation 0.3.2",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1818,22 +1798,6 @@ dependencies = [
"xml-rs",
]
[[package]]
name = "glifo"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash",
"hashbrown 0.17.1",
"log",
"peniko",
"skrifa 0.44.0",
"smallvec",
"vello_common 0.1.0",
]
[[package]]
name = "glifo"
version = "0.3.0"
@@ -1841,14 +1805,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
"png",
"skrifa 0.44.0",
"smallvec",
"vello_common 0.2.0",
"vello_common",
]
[[package]]
@@ -1989,6 +1953,15 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1997,7 +1970,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -2006,7 +1979,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -2418,12 +2391,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
"accesskit_consumer",
]
[[package]]
@@ -2705,6 +2678,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3165,6 +3147,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3226,10 +3218,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.13.1"
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros",
"phf_shared",
@@ -3238,9 +3241,9 @@ dependencies = [
[[package]]
name = "phf_generator"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared",
@@ -3248,9 +3251,9 @@ dependencies = [
[[package]]
name = "phf_macros"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
"phf_generator",
"phf_shared",
@@ -3261,9 +3264,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
dependencies = [
"siphasher",
]
@@ -3718,6 +3721,12 @@ version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "send_wrapper"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
version = "1.0.229"
@@ -3886,8 +3895,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
dependencies = [
"bitflags 2.13.2",
"calloop 0.13.0",
"calloop-wayland-source 0.3.0",
"calloop",
"calloop-wayland-source",
"cursor-icon",
"libc",
"log",
@@ -3904,44 +3913,6 @@ dependencies = [
"xkeysym",
]
[[package]]
name = "smithay-client-toolkit"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
dependencies = [
"bitflags 2.13.2",
"calloop 0.14.5",
"calloop-wayland-source 0.4.1",
"cursor-icon",
"libc",
"log",
"memmap2",
"rustix 1.1.5",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
"wayland-csd-frame",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-experimental",
"wayland-protocols-misc",
"wayland-protocols-wlr",
"wayland-scanner",
"xkeysym",
]
[[package]]
name = "smithay-clipboard"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
dependencies = [
"libc",
"smithay-client-toolkit 0.20.0",
"wayland-backend",
]
[[package]]
name = "smol_str"
version = "0.2.2"
@@ -4390,7 +4361,6 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -4416,6 +4386,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -4534,21 +4515,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vello_common"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
dependencies = [
"bytemuck",
"fearless_simd",
"guillotiere",
"log",
"peniko",
"smallvec",
"thiserror 2.0.21",
]
[[package]]
name = "vello_common"
version = "0.2.0"
@@ -4565,18 +4531,6 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "vello_cpu"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
dependencies = [
"bytemuck",
"glifo 0.2.0",
"hashbrown 0.17.1",
"vello_common 0.1.0",
]
[[package]]
name = "vello_cpu"
version = "0.2.0"
@@ -4585,13 +4539,13 @@ checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
"crossbeam-channel",
"glifo 0.3.0",
"glifo",
"hashbrown 0.17.1",
"ordered-channel",
"png",
"rayon",
"thread_local",
"vello_common 0.2.0",
"vello_common",
]
[[package]]
@@ -4774,32 +4728,6 @@ dependencies = [
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-experimental"
version = "20250721.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-misc"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-plasma"
version = "0.3.12"
@@ -5456,8 +5384,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -5465,7 +5392,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
"bytemuck",
"calloop 0.13.0",
"calloop",
"cfg_aliases",
"concurrent-queue",
"core-foundation",
@@ -5486,7 +5413,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"smithay-client-toolkit 0.19.2",
"smithay-client-toolkit",
"smol_str",
"tracing",
"unicode-segmentation",
@@ -5529,6 +5456,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "write-fonts"
version = "0.48.1"
@@ -5920,3 +5865,23 @@ dependencies = [
"syn 3.0.6",
"winnow",
]
[[patch.unused]]
name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+20 -4
View File
@@ -66,10 +66,11 @@ quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = { version = "0.37", default-features = false }
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
web-time = "1.1"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
@@ -104,3 +105,18 @@ debug = 0
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", 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" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+24 -1
View File
@@ -18,7 +18,13 @@ list (author, source, license) is in ATTRIBUTION.md.
- Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in
ATTRIBUTION.md (Inter, Carlito, Caladea, Liberation, Montserrat, Lato, Open Sans, Roboto,
Merriweather, Playfair Display, Poppins, Nunito Sans, BIZ UD, Shippori Mincho, Noto Sans
Arabic) under the SIL Open Font License 1.1; their licence texts ship with release packages.
Arabic, Noto Sans Thai, Noto Sans Georgian, NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2,
Noto Sans Hebrew) under the SIL Open Font License 1.1; their licence texts ship with release
packages (the exact font files and OFL texts are retained in craft-fonts releases). Native
builds may additionally read craft-fonts' separate Simplified Chinese manifest and embed LXGW
WenKai Lite 1.530 under OFL-1.1; its OFL text ships with release packages, and the exact font
and licence files are retained in craft-fonts with the LXGW Reserved Font Names. That face is
not in the web payload.
- Lucide icon paths embedded in crates/engine/src/icon_data.rs (Insert › Icons), release 1.56.0,
commit 440a1e1dfb1e712505308da46bead3c28b1f1398, https://github.com/lucide-icons/lucide.
ISC License, Copyright (c) 2026 Lucide Icons and Contributors. Icons Lucide derives from Feather
@@ -80,3 +86,20 @@ Code dependencies
(https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile
licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources
and hashes are in ATTRIBUTION.md.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+14 -9
View File
@@ -11,6 +11,8 @@ const VIDEO_EXTS: &[&str] = &["mp4", "m4v", "mov", "webm"];
const CANVAS_ID: &str = "deckcraft_canvas";
const LOADING_ID: &str = "deckcraft_loading";
/// Start the browser shell: install logging, locate the canvas, launch the eframe runner,
/// build the sample or blank deck, and replace the loading element when launch finishes.
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
@@ -36,6 +38,7 @@ pub fn start() {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
}
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
let inbox: Inbox = Inbox::default();
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let q = query();
@@ -74,11 +77,17 @@ struct WebShell {
}
impl eframe::App for WebShell {
/// Delegate one eframe logic pass to the wrapped application.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
// Capture the document and slide now. The bytes arrive later, after the user may
// have switched slides.
self.app.logic(ctx);
}
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
/// and deliver the id, name, and bytes through the inbox.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// Browsers cannot read dropped files synchronously. Consume this pass's drops here, with
// fresh input, and capture the document/slide before the asynchronous read finishes.
for f in std::mem::take(&mut raw.dropped_files) {
let id = self.app.begin_external_file("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
@@ -97,11 +106,7 @@ impl eframe::App for WebShell {
}
});
}
self.app.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+6 -2
View File
@@ -42,8 +42,9 @@ impl eframe::App for App {
apple_events::poll(&self.1, &mut self.0, ctx);
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
/// Forward native raw input to the wrapped application once per pass.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
@@ -180,6 +181,8 @@ fn app_icon() -> Option<egui::IconData> {
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
/// Install logging, parse startup arguments and environment, launch the native app, and
/// propagate an eframe startup failure.
fn main() -> eframe::Result {
// First, so every start-up record (and the engine's panic hook, installed with the first
// Session) is captured; see `logging`.
@@ -255,6 +258,7 @@ fn main() -> eframe::Result {
"DeckCraft",
options,
Box::new(move |cc| {
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
let mut app = SlideApp::new(Session::new(), services());
load_prefs(&mut app);
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
+555 -2
View File
@@ -10,8 +10,10 @@ use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
use crate::{DocState, Result, Selection, Session, Target, TextSel};
use crate::{DocState, HistoryEntry, Result, Selection, Session, Target, TextSel, push_undo};
/// Catalogue text-editing, selection, autocorrect, font, IME, and surrounding-deletion commands
/// with their parameter docs and enabled predicates.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "font.used", "Fonts in Presentation", [], None, "{} → [family] (theme fonts resolved)", has_doc, fonts_used),
@@ -66,6 +68,30 @@ pub fn specs() -> Vec<CommandSpec> {
has_text,
delete
),
// An input method asks for text around the caret to be removed (macOS press-and-hold,
// dictation, Wayland text-input). Internal: the keyboard and control protocol keep the
// existing text.delete spelling. `bytes` says the counts are UTF-8 bytes (winit's
// `Ime::DeleteSurrounding`), otherwise characters, both in the logical text
// `ime_surrounding` publishes: a paragraph mark or soft line break is one `\n` byte, so a
// range may cross paragraphs. The selection and pre-edit are kept; byte ranges round
// inward to whole scalars. A deletion while composing stays in the composition's single
// undo step.
//
// `undoable: false` is deliberate even though a non-composing deletion is undoable: the
// handler pushes its own history entry from a snapshot taken before the composition is
// replaced, so the generic post-run undo hook must not add a duplicate. `journal` stays
// off for the same reason an input-method edit is not a replayable document command.
CommandSpec {
id: "text.deleteSurrounding",
label: "Delete Surrounding Text",
menu: &[],
shortcut: None,
params: "{before, after, bytes?: bool}",
enabled: has_text,
run: delete_surrounding,
journal: false,
undoable: false,
},
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
@@ -111,6 +137,240 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti
Some(sh.text.get_or_insert_with(TextBody::default))
}
/// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query.
///
/// This matches `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; keeping the bound here lets the
/// engine answer without copying a whole body or document.
pub const IME_SURROUNDING_MAX_BYTES: usize = 4000;
/// Bounded committed text around an IME selection, in logical (not visual) order and with UTF-8
/// byte offsets. The current input-method composition (pre-edit) is removed from both the text
/// and the offsets.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeSurrounding {
pub text: String,
pub cursor: usize,
pub anchor: usize,
}
/// How a soft line break reads in the IME view: a newline, like a paragraph break.
///
/// The model stores it as `\u{b}` ([`RunKind::Break`]); publishing that control character would
/// give the platform a text its own `ime_text` filter never produces, so its idea of the lines
/// and our offsets would disagree. Both are one byte, so offsets do not otherwise change.
fn ime_char(ch: char) -> char {
if ch == '\u{b}' { '\n' } else { ch }
}
/// Committed characters of one paragraph (the pre-edit skipped), as `(char index, character)`.
fn committed_chars(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator<Item = (usize, char)> + '_ {
p.runs
.iter()
.flat_map(|run| run.content().chars())
.enumerate()
.filter_map(move |(i, ch)| (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch))))
}
/// The same characters, newest first, without walking the paragraph from its start.
fn committed_chars_rev(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator<Item = (usize, char)> + '_ {
let mut starts = Vec::with_capacity(p.runs.len());
let mut at = 0usize;
for r in &p.runs {
let n = r.char_len();
starts.push((at, n));
at += n;
}
p.runs.iter().zip(starts).rev().flat_map(move |(r, (start, n))| {
let content = r.content();
content.chars().rev().enumerate().filter_map(move |(k, ch)| {
let i = start + n - 1 - k;
(!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch)))
})
})
}
/// The composition's `(paragraph, start, end)` character range, when it is inside the body.
fn composition_range(body: &TextBody, c: Option<&crate::Composition>) -> Option<(usize, usize, usize)> {
let c = c?;
let para = body.paragraphs.get(c.start.0)?;
let start = c.start.1.min(para.char_len());
let end = start.saturating_add(c.len).min(para.char_len());
(start < end).then_some((c.start.0, start, end))
}
/// Return the composition character range only when its paragraph index equals `pi`.
fn comp_in(comp: Option<(usize, usize, usize)>, pi: usize) -> Option<(usize, usize)> {
comp.filter(|(p, _, _)| *p == pi).map(|(_, s, e)| (s, e))
}
/// Clip a model position to the body. An empty body only has position `(0, 0)`.
fn model_pos(body: &TextBody, pos: (usize, usize)) -> Option<(usize, usize)> {
if body.paragraphs.is_empty() {
return (pos == (0, 0)).then_some((0, 0));
}
body.paragraphs.get(pos.0).map(|p| (pos.0, pos.1.min(p.char_len())))
}
/// Walk forward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole
/// characters), stopping at the end of the body. Returns the position reached and the characters
/// and bytes consumed.
///
/// The returned window is bounded by `limit`, but locating `from` inside a paragraph still walks
/// that paragraph's runs from their start: runs do not cache character lengths, and a position is
/// a character index. A megabyte paragraph therefore costs time proportional to the caret's
/// character offset, even though the copied surrounding text stays within `limit`.
fn walk_forward(
body: &TextBody,
comp: Option<(usize, usize, usize)>,
from: (usize, usize),
limit: usize,
bytes: bool,
) -> ((usize, usize), usize, usize) {
let (mut pi, mut at) = from;
let (mut chars, mut used) = (0usize, 0usize);
// `limit` comes from a (possibly hostile) request: every count is checked, never added
// blindly, so an enormous limit cannot overflow.
let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit };
while let Some(para) = body.paragraphs.get(pi) {
for (i, ch) in committed_chars(para, comp_in(comp, pi)) {
if i < at {
continue;
}
let n = ch.len_utf8();
if !fits(used, chars, n) {
return ((pi, i), chars, used);
}
used += n;
chars += 1;
at = i + 1;
}
if pi + 1 >= body.paragraphs.len() {
break;
}
// The paragraph separator between this paragraph and the next.
if !fits(used, chars, 1) {
return ((pi, at), chars, used);
}
used += 1;
chars += 1;
pi += 1;
at = 0;
}
((pi, at), chars, used)
}
/// Walk backward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole
/// characters). Returns the position reached and the characters and bytes consumed.
fn walk_backward(
body: &TextBody,
comp: Option<(usize, usize, usize)>,
from: (usize, usize),
limit: usize,
bytes: bool,
) -> ((usize, usize), usize, usize) {
let (mut pi, mut at) = from;
let (mut chars, mut used) = (0usize, 0usize);
let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit };
loop {
let Some(para) = body.paragraphs.get(pi) else { return ((pi, at), chars, used) };
let mut boundary = at.min(para.char_len());
for (i, ch) in committed_chars_rev(para, comp_in(comp, pi)) {
if i >= boundary {
continue;
}
let n = ch.len_utf8();
if !fits(used, chars, n) {
return ((pi, boundary), chars, used);
}
used += n;
chars += 1;
boundary = i;
}
// This paragraph is consumed down to its start.
if pi == 0 {
return ((0, 0), chars, used);
}
if !fits(used, chars, 1) {
return ((pi, 0), chars, used);
}
used += 1;
chars += 1;
pi -= 1;
at = usize::MAX; // all of the previous paragraph is before us
}
}
/// The committed logical text between two model positions (excluding `end`). `None` when it is
/// longer than `cap` bytes: every caller only needs a bounded answer.
fn logical_between(body: &TextBody, comp: Option<(usize, usize, usize)>, start: (usize, usize), end: (usize, usize), cap: usize) -> Option<String> {
let (a, b) = if start <= end { (start, end) } else { (end, start) };
let mut out = String::new();
for pi in a.0..=b.0 {
let Some(para) = body.paragraphs.get(pi) else { return Some(out) };
let from = if pi == a.0 { a.1 } else { 0 };
let to = if pi == b.0 { b.1 } else { usize::MAX };
for (i, ch) in committed_chars(para, comp_in(comp, pi)) {
if i < from || i >= to {
continue;
}
out.push(ch);
if out.len() > cap {
return None;
}
}
if pi < b.0 {
out.push('\n');
if out.len() > cap {
return None;
}
}
}
Some(out)
}
/// Committed surrounding text for the text being edited.
///
/// Returns `None` when the body or selection is invalid, or when the selection alone is wider
/// than [`IME_SURROUNDING_MAX_BYTES`]. An empty body is `Some` with empty text, which is distinct
/// from an unavailable context. `cursor > anchor` is preserved for a reversed selection.
///
/// Only a bounded window is measured and copied: the query walks at most the budget out from the
/// selection on each side and does not copy the rest of the body. Finding the selection's
/// character offsets inside one paragraph still scans that paragraph's prefix, so the time is not
/// bounded independently of the paragraph length.
pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option<ImeSurrounding> {
let body = body_of(st, t)?;
let comp = composition_range(body, st.composition.as_ref());
let caret = model_pos(body, t.caret)?;
let anchor = model_pos(body, t.anchor)?;
let (start, end) = if caret <= anchor { (caret, anchor) } else { (anchor, caret) };
let selected = logical_between(body, comp, start, end, IME_SURROUNDING_MAX_BYTES)?;
let selection_len = selected.len();
let budget = IME_SURROUNDING_MAX_BYTES.saturating_sub(selection_len);
let (_, _, before_available) = walk_backward(body, comp, start, budget, true);
let (_, _, after_available) = walk_forward(body, comp, end, budget, true);
let mut before = (budget / 2).min(before_available);
let mut after = (budget - before).min(after_available);
before = (budget - after).min(before_available);
after = (budget - before).min(after_available);
let (window_start, _, _) = walk_backward(body, comp, start, before, true);
let (window_end, _, _) = walk_forward(body, comp, end, after, true);
let before_text = logical_between(body, comp, window_start, start, budget)?;
let after_text = logical_between(body, comp, end, window_end, budget)?;
let before_len = before_text.len();
let mut text = before_text;
text.push_str(&selected);
text.push_str(&after_text);
// The budget arithmetic above already bounds this; refuse rather than publish more.
if text.len() > IME_SURROUNDING_MAX_BYTES {
return None;
}
// The caret and anchor are the selection's own endpoints, so their offsets inside the
// window are the window's start plus either end of the selected text.
let (cursor, anchor) = if caret >= anchor { (before_len + selection_len, before_len) } else { (before_len, before_len + selection_len) };
Some(ImeSurrounding { text, cursor, anchor })
}
/// Edit the active text body and selection; refits auto-sizing text boxes afterwards.
pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result<T> {
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?;
@@ -932,7 +1192,9 @@ fn compose(s: &mut Session, p: &Value) -> Result<Value> {
// A composition whose text was changed under it is kept as it is; a new one starts.
let current = match current {
Some(c) if !composition_fits(s.doc()?, &c) => {
s.end_composition();
// The text changed under the pre-edit: the app settled it, so the platform's copy
// must be cancelled too.
s.settle_composition();
None
}
c => c,
@@ -1154,6 +1416,93 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
ok()
}
/// A `before`/`after` count from a control or input-method request.
///
/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is
/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that
/// would delete different text than asked.
fn count_param(p: &Value, name: &str) -> usize {
p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX))
}
/// Delete at most the requested character or byte counts around the selection or pre-edit,
/// preserving selection direction and composition boundaries and recording one undo step
/// outside composition.
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
let before = count_param(p, "before");
let after = count_param(p, "after");
let bytes = bool_or(p, "bytes", false);
let st = s.doc()?;
let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?;
let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?;
let comp = composition_range(body, st.composition.as_ref());
// The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a
// paragraph separator and for a soft line break. A range may therefore cross paragraphs.
let ordered = {
let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
if a <= b { (a, b) } else { (b, a) }
};
// The selection (or the input method's pre-edit) stays exactly where it is. Only whole
// scalars fully inside the requested before/after ranges around it are removed.
let (start, end) = match comp {
Some((pi, cs, ce)) => ((pi, cs), (pi, ce)),
None => ordered,
};
let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes);
let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes);
let deleted = before_chars + after_chars;
if deleted == 0 {
return Ok(json!({"deleted": 0}));
}
let before_doc = st.doc.clone();
let before_sel = st.selection.clone();
let composing = st.composition.is_some();
let vertex_kind = s.tool.vertex_kind.clone();
edit_text(s, |body, t| {
// Delete the after-range first, so the positions of the earlier range stay valid; then
// the text before the selection.
if after_chars > 0 {
ed::delete(body, end, to);
}
if before_chars > 0 {
ed::delete(body, from, start);
}
t.anchor = remap_after_delete(t.anchor, start, from, before_chars);
t.caret = remap_after_delete(t.caret, start, from, before_chars);
t.line_end = None;
})?;
if composing {
if let Some(c) = s.doc_mut()?.composition.as_mut() {
c.start = if before_chars == 0 {
start
} else if from.0 == start.0 {
(start.0, start.1 - before_chars)
} else {
from
};
}
} else {
push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel, vertex_kind });
}
Ok(json!({"deleted": deleted}))
}
/// Map a text position across the deletion of `before_chars` characters that begins at `start`
/// and reaches back to `from`. Within the start paragraph its characters shift; from an earlier
/// paragraph those paragraphs merge, so later paragraph numbers shift too.
fn remap_after_delete(pos: (usize, usize), start: (usize, usize), from: (usize, usize), before_chars: usize) -> (usize, usize) {
if before_chars == 0 || pos < start {
return pos;
}
if from.0 == start.0 {
// Only endpoints in the start paragraph move; later paragraphs keep their text.
if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos }
} else {
(from.0 + (pos.0 - start.0), if pos.0 == start.0 { from.1 + (pos.1 - start.1) } else { pos.1 })
}
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
}
@@ -1583,6 +1932,210 @@ mod rtl_promote_tests {
}
}
#[cfg(test)]
mod ime_surrounding_tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use serde_json::json;
use crate::Session;
/// Test helper: insert and enter a rectangle containing `aé😀b`, returning its shape id.
fn edit(s: &mut Session) -> u64 {
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 240, 60], "text": "aé😀b"})).unwrap();
let id = r["id"].as_u64().unwrap();
s.execute("text.edit", &json!({"id": id})).unwrap();
id
}
/// Test helper: return the identified shape's text, or an empty string when it is gone.
fn text(s: &Session, id: u64) -> String {
let id = deckcraft_model::ShapeId(u32::try_from(id).unwrap());
s.doc().unwrap().shape(id).and_then(|sh| sh.text.as_ref()).map(deckcraft_model::text::TextBody::text).unwrap_or_default()
}
/// Test helper: set an in-paragraph text selection through the engine command path.
fn select(s: &mut Session, anchor: usize, caret: usize) {
s.execute("text.select", &json!({"anchor": [0, anchor], "caret": [0, caret]})).unwrap();
}
/// DeleteSurrounding removes only the requested text around the selection. The selection
/// itself and its direction stay, so the following commit replaces it.
#[test]
fn selected_text_survives_the_requested_characters_or_bytes_around_it() {
let mut s = Session::with_new();
let id = edit(&mut s);
select(&mut s, 3, 1); // reversed: caret before anchor, selecting é😀
s.delete_surrounding(1, 1, false).unwrap();
assert_eq!(text(&s, id), "é😀", "a and b go; the selected run stays");
let t = s.doc().unwrap().selection.text.clone().unwrap();
assert_eq!((t.anchor, t.caret), ((0, 2), (0, 0)), "both endpoints shift and stay reversed");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "aé😀b");
select(&mut s, 1, 3);
s.delete_surrounding(1, 1, true).unwrap();
assert_eq!(text(&s, id), "é😀", "byte counts around the selection delete a and b too");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "aé😀b");
}
/// A byte range removes only whole scalars fully contained in it. A count that ends inside
/// a character leaves that character whole (and undeleted), never splitting it.
#[test]
fn byte_counts_round_inward_to_whole_characters() {
let mut s = Session::with_new();
let id = edit(&mut s);
select(&mut s, 2, 2); // after é: bytes 0..3
s.delete_surrounding(1, 0, true).unwrap();
assert_eq!(text(&s, id), "aé😀b", "one byte inside é is not the whole scalar");
s.delete_surrounding(2, 0, true).unwrap();
assert_eq!(text(&s, id), "a😀b", "two bytes reach é's start, so the whole é goes");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "aé😀b");
select(&mut s, 2, 2);
s.delete_surrounding(0, 1, true).unwrap();
assert_eq!(text(&s, id), "aé😀b", "one byte into the emoji is not the whole scalar");
s.delete_surrounding(0, 4, true).unwrap();
assert_eq!(text(&s, id), "aéb", "four bytes contain the whole emoji");
}
/// The committed query is logical text with UTF-8 byte offsets; it keeps a reversed
/// selection, removes the model-stored pre-edit, and treats an empty body as available.
#[test]
fn surrounding_text_is_committed_logical_and_bounded() {
use super::{IME_SURROUNDING_MAX_BYTES, ime_surrounding};
let mut s = Session::with_new();
let id = edit(&mut s);
let st = s.doc().unwrap();
let t = st.selection.text.clone().unwrap();
let q = ime_surrounding(st, &t).unwrap();
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("aé😀b", 8, 8));
s.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap();
let st = s.doc().unwrap();
let t = st.selection.text.clone().unwrap();
let q = ime_surrounding(st, &t).unwrap();
assert_eq!((q.cursor, q.anchor), (1, 7), "reversed selection keeps caret before anchor");
assert_eq!(q.text, "aé😀b");
s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap();
s.execute("text.move", &json!({"to": "end"})).unwrap();
s.compose_text("日本", None).unwrap();
let st = s.doc().unwrap();
let t = st.selection.text.clone().unwrap();
let q = ime_surrounding(st, &t).unwrap();
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab", 2, 2), "pre-edit is not committed text");
s.execute("text.set", &json!({"id": id, "text": ""})).unwrap();
let st = s.doc().unwrap();
let t = st.selection.text.clone().unwrap();
let q = ime_surrounding(st, &t).unwrap();
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("", 0, 0), "empty body is available");
let long = "a".repeat(IME_SURROUNDING_MAX_BYTES + 1500);
s.execute("text.set", &json!({"id": id, "text": long})).unwrap();
s.execute("text.move", &json!({"to": "end"})).unwrap();
let st = s.doc().unwrap();
let t = st.selection.text.clone().unwrap();
let q = ime_surrounding(st, &t).unwrap();
assert!(q.text.len() <= IME_SURROUNDING_MAX_BYTES, "{}", q.text.len());
assert_eq!(q.cursor, q.text.len(), "caret stays at the end of the bounded window");
}
/// Paragraph marks are part of the logical text the platform sees (`\n`), so a deletion may
/// remove one and merge the paragraphs, exactly as the published offsets say.
#[test]
fn paragraph_marks_are_deletable_logical_text() {
use super::ime_surrounding;
let mut s = Session::with_new();
let id = edit(&mut s);
s.execute("text.set", &json!({"id": id, "text": "ab\ncd"})).unwrap();
// The caret at the start of the second paragraph: after "ab" and its paragraph mark.
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap();
let st = s.doc().unwrap();
let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap();
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab\ncd", 3, 3));
// One byte before the caret is that paragraph mark.
s.delete_surrounding(1, 0, true).unwrap();
assert_eq!(text(&s, id), "abcd", "the mark goes and the paragraphs merge");
let t = s.doc().unwrap().selection.text.clone().unwrap();
assert_eq!(t.caret, (0, 2), "the caret stays where the merged text continues");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "ab\ncd");
// Characters count the same mark.
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap();
s.delete_surrounding(1, 0, false).unwrap();
assert_eq!(text(&s, id), "abcd");
s.execute("edit.undo", &json!({})).unwrap();
// And from the other side: one character after the end of the first paragraph.
s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap();
s.delete_surrounding(0, 1, false).unwrap();
assert_eq!(text(&s, id), "abcd");
}
/// A soft line break (Shift+Enter) is published as a newline, like a paragraph break: the
/// platform must never see the model's `\u{b}`, which its own text filter would reject.
#[test]
fn soft_line_breaks_read_as_newlines() {
use super::ime_surrounding;
let mut s = Session::with_new();
let id = edit(&mut s);
s.execute("text.autocorrect", &json!({"on": false})).unwrap(); // isolate logical line-break coordinates
s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap();
s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap();
s.execute("text.insert", &json!({"text": "\u{b}"})).unwrap();
assert_eq!(text(&s, id), "a\u{b}b", "a line break inside one paragraph");
let st = s.doc().unwrap();
let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap();
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("a\nb", 2, 2));
// The break is one logical byte, so removing it removes one character.
s.delete_surrounding(1, 0, true).unwrap();
assert_eq!(text(&s, id), "ab");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "a\u{b}b");
}
/// A count that does not fit a `usize` (32-bit wasm) is clamped, never truncated to a
/// different number that would delete other text than the platform asked for.
#[test]
fn out_of_range_counts_clamp_instead_of_truncating() {
let mut s = Session::with_new();
let id = edit(&mut s);
select(&mut s, 1, 1);
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!(expected > 0, "the count is clamped, not wrapped to nothing");
assert_eq!(super::count_param(&json!({}), "before"), 0);
}
/// A non-composing input-method deletion is exactly one undo step; the manual history entry
/// must not be duplicated by the generic command wrapper.
#[test]
fn a_non_composing_ime_deletion_is_one_undo_step() {
let mut s = Session::with_new();
let id = edit(&mut s);
let undo = s.doc().unwrap().history.undo.len();
select(&mut s, 3, 3);
s.delete_surrounding(1, 0, false).unwrap();
assert_eq!(text(&s, id), "aéb");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one history entry, not two");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), "aé😀b");
}
}
#[cfg(test)]
mod replace_font_tests {
use serde_json::json;
+11 -1
View File
@@ -49,7 +49,7 @@ impl Session {
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
/// Returns `{handled: bool, …}`.
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
self.end_composition();
self.settle_composition();
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
let k = key_token(key);
@@ -266,6 +266,16 @@ impl Session {
self.execute("text.commit", &json!({"text": text}))
}
/// Remove text around the composition caret or selection, as an input method asks
/// (`ImeEvent::DeleteSurrounding`/`DeleteSurroundingBytes`). Counts are characters or UTF-8
/// bytes of the logical text the surrounding query publishes, so a range may cross a
/// paragraph mark (read as one `\n` byte) and whole scalars only are removed; out-of-range
/// counts are clamped. A deletion while composing stays inside the composition's single undo
/// step.
pub fn delete_surrounding(&mut self, before: usize, after: usize, bytes: bool) -> Result<Value> {
self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes}))
}
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
/// starts with everything selected. False when there is nowhere to type (or nothing to type
/// and nothing being edited).
+33 -2
View File
@@ -493,6 +493,8 @@ pub struct Session {
/// Presentations a host read off the UI thread, waiting for `file.adopt` (by token).
loaded: Vec<(u64, cmd::file::LoadedFile)>,
next_loaded: u64,
/// Compositions the application settled itself (see [`Session::composition_settles`]).
composition_settles: u64,
}
impl Default for Session {
@@ -502,6 +504,8 @@ impl Default for Session {
}
impl Session {
/// Install the process panic hook and construct an empty session with default preferences,
/// clipboard, tool state, and no active document.
pub fn new() -> Self {
guard::install_panic_hook();
Session {
@@ -524,6 +528,7 @@ impl Session {
panics_caught: 0,
loaded: vec![],
next_loaded: 0,
composition_settles: 0,
}
}
/// Hand over a presentation read on another thread ([`cmd::file::read_presentation_file`]);
@@ -610,11 +615,13 @@ impl Session {
self.guarded(id, |s| s.execute_unguarded(id, params))
}
/// Resolve and run a command, settling composition first, checking eligibility, rolling back a
/// failed outer command, and committing the resulting undo entry or journal record.
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
// Any other edit (or a click, a key, undo) settles an input method's composition first.
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
self.end_composition();
self.settle_composition();
}
if let Err(e) = (spec.enabled)(self) {
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
@@ -688,6 +695,9 @@ impl Session {
/// Keep an input method's composition as it stands (#101): its text stays and the whole
/// composition becomes one undo step. Nothing happens without one.
///
/// This is also how the input method's own commit ends a composition: the platform has
/// already finished that one, so it must not ask for an interrupt.
pub fn end_composition(&mut self) {
let vertex_kind = self.tool.vertex_kind.clone();
let Some(st) = self.active_mut() else { return };
@@ -697,6 +707,27 @@ impl Session {
}
}
/// Settle a composition because the application did something else (a click, a key, a
/// command, a target change) rather than because the input method committed it.
///
/// The platform still holds its own copy of that composition, so a host must publish one
/// `should_interrupt_composition` for each increment of [`Self::composition_settles`]: the
/// backend then cancels it instead of committing its text into whatever is selected next.
pub fn settle_composition(&mut self) {
if self.active().is_some_and(|d| d.composition.is_some()) {
self.composition_settles = self.composition_settles.wrapping_add(1);
}
self.end_composition();
}
/// How many compositions the application settled itself (clicks, keys, other commands).
///
/// A host compares this with the value it last published; every increment is one platform
/// composition that must be interrupted. The input method's own commits do not count.
pub fn composition_settles(&self) -> u64 {
self.composition_settles
}
/// Change only the selection (no undo step).
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
let st = self.doc_mut()?;
@@ -712,7 +743,7 @@ impl Session {
st.revision += 1;
// A composition belongs to the text body it began in: leaving that body settles it.
if st.composition.is_some() && owner(&st.selection) != before {
self.end_composition();
self.settle_composition();
}
Ok(())
}
+34 -1
View File
@@ -4088,6 +4088,37 @@ fn tables_with_a_file_defined_style_edit_and_resize() {
use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef};
let mut s = session();
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]]));
// A file-defined weight needs a real regular/bold pair, independent of missing Inter
// or whichever system face happened to load first. Leave bold unset for the table style.
let fonts = deckcraft_fonts::FontDb::global();
let regular = fonts.face("BIZ UDPGothic", "Regular");
let bold = fonts.face("BIZ UDPGothic", "Bold");
let metric_pair = regular.family == "BIZ UDPGothic"
&& bold.family == "BIZ UDPGothic"
&& regular.style == "Regular"
&& bold.style == "Bold"
&& regular.weight < bold.weight;
// Required native builds validate and embed both BIZ faces (crates/fonts/build.rs);
// optional local builds may only have Regular, so the weight assertion is skipped there.
assert!(
metric_pair || option_env!("CRAFT_FONTS_REQUIRED").is_none(),
"required native build is missing the BIZ UDPGothic Regular/Bold metric pair"
);
if metric_pair {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let slide = std::sync::Arc::make_mut(&mut doc.slides[0]);
let deckcraft_model::ShapeKind::Table(table) = &mut slide.shape_mut(id).unwrap().kind else { panic!("not a table") };
for cell in &mut table.rows[0].cells {
for paragraph in &mut cell.text.paragraphs {
for run in &mut paragraph.runs {
run.props.font = Some("BIZ UDPGothic".into());
run.props.bold = None;
}
}
}
} else {
eprintln!("optional BIZ pair absent: skipping weight-metric assertion; edit/resize checks still run");
}
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
let built_in = caret_point(&s, 0, 8);
s.execute("text.exit", &json!({})).unwrap();
@@ -4112,7 +4143,9 @@ fn tables_with_a_file_defined_style_edit_and_resize() {
s.execute("edit.deselect", &json!({})).unwrap();
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
let p = caret_point(&s, 0, 8);
assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}");
if metric_pair {
assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}");
}
s.execute("text.exit", &json!({})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
// A double-click selects the word drawn there.
+1 -1
View File
@@ -459,7 +459,7 @@ impl Session {
}
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
if !matches!(ev.kind, PointerKind::Move) {
self.end_composition();
self.settle_composition();
}
match ev.kind {
PointerKind::Move => {
+1 -1
View File
@@ -15,4 +15,4 @@ harfrust = { workspace = true }
unicode-bidi = { workspace = true }
log = { workspace = true }
# Last-resort face only (Ubuntu Light); no UI code.
epaint_default_fonts = "0.36"
epaint_default_fonts = "0.37"
+466 -29
View File
@@ -1,27 +1,61 @@
//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its
//! `docs/integration.md`). With `CRAFT_FONTS_DIR=<checkout>` the fonts in its
//! `fonts/latin-manifest.txt` (presentation fonts) and `fonts/manifest.txt` are embedded as
//! `CRAFT_FONTS`, Latin first; unset, `CRAFT_FONTS` is empty. Web (wasm32) builds embed only Inter
//! Regular/Bold and BIZ UDPGothic Regular, to stay within the web size budget. It only reads the
//! local checkout: no network.
//! `CRAFT_FONTS`, Latin first. Native builds additionally read
//! `fonts/simplified-chinese-manifest.txt`; it is optional for ordinary local builds but a required
//! native input (`CRAFT_FONTS_REQUIRED`) fails without it. Unset, `CRAFT_FONTS` is empty. Web
//! (wasm32) builds embed only Inter Regular/Bold (or a supported Variable face) and BIZ UDPGothic
//! Regular, to stay within the web size budget; native builds also carry BIZ UDPGothic Bold.
//! Required builds verify the faces they promise survive exclusion (and, on wasm, the web filter),
//! so a pin without the Latin manifest or the native bold face fails instead of silently dropping
//! them. It only reads the local checkout: no network.
use std::fmt::Write as _;
use std::path::PathBuf;
/// Families DeckCraft never embeds (AGENTS.md §1: no Adobe-authored visual design).
const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif", "Noto Sans CJK SC"];
/// Families DeckCraft never embeds (AGENTS.md §1: no Adobe-authored visual design, and no Noto
/// CJK rebrand of Source Han on any platform).
const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif"];
/// Reject source families prohibited by the asset policy, including Source Han and every
/// Noto CJK/Serif/Mono CJK variant.
fn is_excluded_family(family: &str) -> bool {
EXCLUDED_FAMILIES.contains(&family)
|| family.starts_with("Source Han Sans")
|| family.starts_with("Source Han Serif")
|| family.starts_with("Noto Sans CJK")
|| family.starts_with("Noto Serif CJK")
|| family.starts_with("Noto Sans Mono CJK")
}
/// The generated initialisers plus the faces that were actually embedded after exclusion and web
/// filtering. Validation must use the latter, not the raw manifest text.
#[derive(Default)]
struct FontInput {
source: String,
faces: Vec<(String, String)>,
}
impl FontInput {
/// Concatenate another input's generated source and embedded-face list into this one.
fn append(&mut self, other: Self) {
self.source.push_str(&other.source);
self.faces.extend(other.faces);
}
}
/// Read font-input environment and target architecture, write `craft_fonts.rs`, report font-input
/// failures as warnings unless required, and always fail when the generated file cannot be written.
fn main() {
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
match craft_fonts(&dir) {
Ok(entries) => src.push_str(&entries),
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
}
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
}
let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from);
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
match input_fonts(dir.as_deref(), wasm, required) {
Ok(entries) => src.push_str(&entries),
Err(e) if required => println!("cargo::error={e}"),
Err(e) => println!("cargo::warning=building without craft-fonts: {e}"),
}
src.push_str("];\n");
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
@@ -31,30 +65,88 @@ fn main() {
}
/// One `CraftFont { .. }` initialiser per manifest line.
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
let mut out = String::new();
// The Latin presentation fonts are optional within craft-fonts (older checkouts lack them).
let latin = dir.join("fonts/latin-manifest.txt");
println!("cargo::rerun-if-changed={}", latin.display());
if latin.exists() {
out.push_str(&manifest(dir, &latin)?);
}
out.push_str(&manifest(dir, &dir.join("fonts/manifest.txt"))?);
Ok(out)
fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result<String, String> {
load_fonts(dir, wasm, required).map(|input| input.source)
}
fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result<String, String> {
/// Use no fonts when the directory is absent unless required; otherwise load the manifests.
fn load_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result<FontInput, String> {
match dir {
Some(dir) => craft_fonts(dir, wasm, required),
None if required => Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()),
None => Ok(FontInput::default()),
}
}
/// Load optional Latin input, required main input, and native-only Chinese input, then validate
/// the promised required faces after filtering.
fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<FontInput, String> {
let mut input = FontInput::default();
// The Latin presentation fonts are optional within craft-fonts (older checkouts lack them).
// A malformed or incomplete optional manifest is skipped as a unit: the main Japanese/symbol/RTL
// faces and the native Chinese addition stay usable, and required builds still fail validation.
let latin = dir.join("fonts/latin-manifest.txt");
println!("cargo::rerun-if-changed={}", latin.display());
let latin_exists = match latin.try_exists() {
Ok(exists) => exists,
Err(e) if required => return Err(format!("{}: {e}", latin.display())),
Err(e) => {
println!("cargo::warning=skipping optional Latin fonts: {}: {e}", latin.display());
false
}
};
if latin_exists {
match manifest(dir, &latin, wasm) {
Ok(entries) => input.append(entries),
Err(e) if required => return Err(e),
Err(e) => println!("cargo::warning=skipping optional Latin fonts: {e}"),
}
}
input.append(manifest(dir, &dir.join("fonts/manifest.txt"), wasm)?);
// Native consumers opt in; older craft-fonts checkouts need not carry this manifest.
// An invalid optional addition must never discard the main Japanese/symbol/RTL faces.
if !wasm {
let chinese = dir.join("fonts/simplified-chinese-manifest.txt");
println!("cargo::rerun-if-changed={}", chinese.display());
let exists = match chinese.try_exists() {
Ok(exists) => exists,
Err(e) if required => return Err(format!("{}: {e}", chinese.display())),
Err(e) => {
println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", chinese.display());
false
}
};
if exists {
match manifest(dir, &chinese, false) {
Ok(entries) => input.append(entries),
Err(e) if required => return Err(e),
Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {e}"),
}
} else if required {
// A required native font input that silently lacks the reviewed Chinese manifest
// would make the Chinese UI/canvas test pass only by skipping.
return Err(format!("{}: CRAFT_FONTS_REQUIRED is set but the simplified-Chinese font manifest is missing", chinese.display()));
}
}
if required {
validate_required_faces(&input.faces, wasm)?;
}
Ok(input)
}
/// Parse one manifest, reject malformed rows, exclude banned families, apply the wasm filter,
/// canonicalise assets, and emit initialisers.
fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool) -> Result<FontInput, String> {
println!("cargo::rerun-if-changed={}", manifest.display());
let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
let mut input = FontInput::default();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
};
// DeckCraft's asset policy: no Adobe-authored typefaces, even openly licensed ones.
if EXCLUDED_FAMILIES.contains(family) {
if is_excluded_family(family) {
continue;
}
if wasm && !matches!((*family, *style), ("BIZ UDPGothic", "Regular") | ("Inter", "Regular" | "Bold" | "Variable")) {
@@ -63,12 +155,357 @@ fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result<String,
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
println!("cargo::rerun-if-changed={}", path.display());
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
input.faces.push(((*family).to_owned(), (*style).to_owned()));
let _ = writeln!(
out,
input.source,
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},",
scripts.join(", "),
path.display().to_string(),
);
}
Ok(out)
Ok(input)
}
/// Required builds must actually carry the faces the platform promises: Inter Regular and Bold
/// (or one Variable face) and BIZ UDPGothic Regular everywhere, BIZ UDPGothic Bold natively (the
/// weight-pair metric test relies on it), plus LXGW WenKai Lite Regular for native builds. This
/// runs after exclusion and, on wasm, the web filter, so an absent manifest, empty/all-excluded
/// rows, a wrong-family row or a partial catalog cannot satisfy the gate. Wasm deliberately
/// embeds only BIZ Regular to stay within the web size budget.
fn validate_required_faces(faces: &[(String, String)], wasm: bool) -> Result<(), String> {
let has = |family: &str, style: &str| faces.iter().any(|(f, s)| f == family && s == style);
let inter = has("Inter", "Variable") || (has("Inter", "Regular") && has("Inter", "Bold"));
let biz_regular = has("BIZ UDPGothic", "Regular");
let biz_bold = wasm || has("BIZ UDPGothic", "Bold");
let chinese = wasm || has("LXGW WenKai Lite", "Regular");
let mut missing = Vec::new();
if !inter {
missing.push("Inter Regular/Bold (or Inter Variable)");
}
if !biz_regular {
missing.push("BIZ UDPGothic Regular");
}
if !biz_bold {
missing.push("BIZ UDPGothic Bold");
}
if !chinese {
missing.push("LXGW WenKai Lite Regular");
}
if missing.is_empty() {
Ok(())
} else {
Err(format!("CRAFT_FONTS_REQUIRED is set but the embedded font set after filtering lacks {}", missing.join(" and ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
const CHINESE_ROW: &str = "LXGW WenKai Lite | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
struct InputFixture(PathBuf);
impl InputFixture {
/// Test helper: create a unique temporary fixture with a main manifest and asset file.
fn new() -> Self {
let path = std::env::temp_dir().join(format!("deck-font-input-{}-{}", std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&path).unwrap();
std::fs::create_dir(path.join("fonts")).unwrap();
std::fs::write(path.join("fonts/fixture.ttf"), b"build-input path fixture").unwrap();
std::fs::write(
path.join("fonts/manifest.txt"),
"BIZ UDPGothic | Regular | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
BIZ UDPGothic | Bold | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
)
.unwrap();
Self(path)
}
/// Test helper: replace the fixture's main manifest contents.
fn main(&self, text: &str) {
std::fs::write(self.0.join("fonts/manifest.txt"), text).unwrap();
}
/// Test helper: write the fixture's optional Latin manifest.
fn latin(&self, text: &str) {
std::fs::write(self.0.join("fonts/latin-manifest.txt"), text).unwrap();
}
/// Test helper: remove the optional Latin manifest.
fn no_latin(&self) {
let _ = std::fs::remove_file(self.0.join("fonts/latin-manifest.txt"));
}
/// Test helper: install a valid Inter Regular/Bold Latin manifest.
fn valid_latin(&self) {
self.latin(
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
);
}
/// Test helper: write the optional native Chinese manifest with the supplied rows.
fn optional(&self, text: &str) {
std::fs::write(self.0.join("fonts/simplified-chinese-manifest.txt"), text).unwrap();
}
}
impl Drop for InputFixture {
/// Test helper: best-effort removal of the temporary fixture directory.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A required build without a font directory fails; an optional build embeds no fonts.
#[test]
fn required_input_cannot_silently_disappear() {
assert!(input_fonts(None, false, true).unwrap_err().contains("CRAFT_FONTS_DIR"));
assert_eq!(input_fonts(None, false, false).unwrap(), "");
}
/// A malformed optional Chinese manifest, or one naming a missing asset, leaves the main catalog
/// unchanged in optional builds, while a required build fails.
#[test]
fn broken_optional_manifest_preserves_the_main_fonts() {
let fixture = InputFixture::new();
let main = input_fonts(Some(&fixture.0), false, false).unwrap();
fixture.optional("malformed row");
assert_eq!(input_fonts(Some(&fixture.0), false, false).unwrap(), main);
assert!(input_fonts(Some(&fixture.0), false, true).is_err());
fixture.optional("LXGW WenKai Lite | Regular | missing.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid");
assert_eq!(input_fonts(Some(&fixture.0), false, false).unwrap(), main);
assert!(input_fonts(Some(&fixture.0), false, true).is_err());
}
/// Adding or breaking native Chinese input must not change the wasm font initialiser bytes.
#[test]
fn native_opt_in_does_not_change_wasm_embedded_bytes() {
let fixture = InputFixture::new();
fixture.valid_latin();
let before = input_fonts(Some(&fixture.0), true, true).unwrap();
fixture.optional(CHINESE_ROW);
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
assert!(native.contains("LXGW WenKai Lite"));
assert_eq!(input_fonts(Some(&fixture.0), true, true).unwrap(), before);
fixture.optional("malformed row");
assert_eq!(input_fonts(Some(&fixture.0), true, true).unwrap(), before);
}
/// Required native builds fail without the Chinese manifest; optional native and wasm builds
/// stay valid.
#[test]
fn required_native_input_requires_the_chinese_manifest() {
let fixture = InputFixture::new();
fixture.valid_latin();
assert!(input_fonts(Some(&fixture.0), false, true).unwrap_err().contains("simplified-Chinese"));
// Optional native builds and wasm keep working without it.
assert!(input_fonts(Some(&fixture.0), false, false).is_ok());
assert!(input_fonts(Some(&fixture.0), true, true).is_ok());
}
/// Every prohibited Source Han/Noto CJK family is excluded while permitted CJK, Latin, and
/// Hebrew faces survive.
#[test]
fn all_noto_cjk_families_are_excluded_from_every_platform() {
for family in [
"Noto Sans CJK SC",
"Noto Sans CJK TC",
"Noto Sans CJK JP",
"Noto Sans CJK KR",
"Noto Sans CJK HK",
"Noto Serif CJK SC",
"Noto Serif CJK JP",
"Noto Sans Mono CJK SC",
"Source Han Sans SC",
"Source Han Serif JP",
] {
assert!(is_excluded_family(family), "{family} would be embedded");
}
for family in ["LXGW WenKai Lite", "Noto Sans Hebrew", "Inter", "Noto Sans Symbols 2"] {
assert!(!is_excluded_family(family), "{family} is an allowed input");
}
let fixture = InputFixture::new();
fixture.valid_latin();
let mut rows = String::from(CHINESE_ROW);
for family in ["Noto Sans CJK SC", "Noto Serif CJK JP", "Noto Sans Mono CJK KR"] {
rows.push_str(&format!("{family} | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n"));
}
fixture.optional(&rows);
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
assert!(!native.contains("Noto Sans CJK") && !native.contains("Noto Serif CJK") && !native.contains("Noto Sans Mono CJK"));
assert!(native.contains("BIZ UDPGothic"), "the allowed main manifest is retained");
assert!(native.contains("LXGW WenKai Lite"), "the valid Chinese row is retained");
}
/// A required native Chinese manifest must embed LXGW WenKai Lite Regular, not merely list a
/// nonempty manifest.
#[test]
fn required_native_chinese_manifest_must_emit_the_reviewed_face() {
let fixture = InputFixture::new();
fixture.valid_latin();
let excluded = "Noto Sans CJK SC | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
Noto Serif CJK JP | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
let wrong_family = "LXGW WenKai | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
for rows in ["", excluded, wrong_family] {
fixture.optional(rows);
let e = input_fonts(Some(&fixture.0), false, true).unwrap_err();
assert!(e.contains("LXGW WenKai Lite Regular"), "rows={rows:?}: {e}");
assert!(input_fonts(Some(&fixture.0), false, false).is_ok(), "optional builds still preserve the valid core");
}
fixture.optional(CHINESE_ROW);
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
assert!(native.contains("CraftFont { family: \"LXGW WenKai Lite\", style: \"Regular\""), "{native}");
assert!(native.contains("BIZ UDPGothic") && native.contains("Inter"), "the rest of the required catalog is retained");
}
/// A broken optional Latin manifest is skipped as a unit, preserving main and valid Chinese
/// faces; required builds still fail.
#[test]
fn optional_broken_latin_manifest_is_skipped_without_losing_core_or_chinese() {
let fixture = InputFixture::new();
fixture.optional(CHINESE_ROW);
let missing_asset = "Inter | Regular | fonts/missing.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
for latin in [None, Some("malformed row"), Some(missing_asset)] {
match latin {
Some(text) => fixture.latin(text),
None => fixture.no_latin(),
}
for wasm in [false, true] {
let catalog = input_fonts(Some(&fixture.0), wasm, false).unwrap();
assert!(catalog.contains("BIZ UDPGothic"), "wasm={wasm}: the main manifest is retained");
assert!(!catalog.contains("Inter"), "wasm={wasm}: the broken Latin manifest is skipped as a unit");
if !wasm {
assert!(catalog.contains("LXGW WenKai Lite"), "the valid Chinese addition is retained");
}
}
assert!(input_fonts(Some(&fixture.0), false, true).is_err(), "required builds must not skip the missing required faces");
}
}
/// The reviewed Latin catalog keeps 43 permitted faces plus BIZ and LXGW natively, while wasm
/// remains a three-face subset.
#[test]
fn full_prior_latin_catalog_keeps_permitted_native_families() {
let fixture = InputFixture::new();
fixture.latin(include_str!("tests/fixtures/latin-families.txt"));
fixture.optional(CHINESE_ROW);
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
assert_eq!(native.matches("CraftFont {").count(), 46, "43 permitted Latin faces plus BIZ Regular/Bold and LXGW");
for family in [
"Carlito",
"Caladea",
"Liberation Serif",
"Montserrat",
"Lato",
"Open Sans",
"Roboto",
"Merriweather",
"Playfair Display",
"Poppins",
"Nunito Sans",
] {
assert!(native.contains(family), "missing prior presentation family {family}");
}
let web = input_fonts(Some(&fixture.0), true, true).unwrap();
assert_eq!(web.matches("CraftFont {").count(), 3, "Inter Regular/Bold and BIZ only");
for catalog in [&native, &web] {
assert!(!catalog.contains("Source Sans 3"));
assert!(!catalog.contains("Source Serif 4"));
}
}
/// Required builds without the Latin catalog fail because Inter Regular/Bold (or Variable) is
/// mandatory.
#[test]
fn required_builds_need_the_latin_catalog_for_inter() {
let fixture = InputFixture::new();
fixture.optional(CHINESE_ROW);
for wasm in [true, false] {
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
assert!(e.contains("Inter Regular/Bold"), "wasm={wasm}: {e}");
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
}
}
/// A single Inter weight, non-Inter substitutes, or excluded faces cannot satisfy the required
/// Inter contract.
#[test]
fn required_builds_reject_partial_or_unrelated_faces() {
let fixture = InputFixture::new();
fixture.optional(CHINESE_ROW);
for latin in [
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
"Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
"Inter | Italic | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
Inter | SemiBold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
"Carlito | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
"Source Sans 3 | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
Source Sans 3 | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
] {
fixture.latin(latin);
for wasm in [true, false] {
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
assert!(e.contains("Inter Regular/Bold"), "wasm={wasm}: {e}");
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
}
}
}
/// BIZ UDPGothic Regular is required on every platform; required native builds additionally
/// need Bold.
#[test]
fn required_builds_need_biz_udpgothic_regular() {
let fixture = InputFixture::new();
fixture.valid_latin();
fixture.main(
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
);
fixture.optional(CHINESE_ROW);
for wasm in [true, false] {
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
assert!(e.contains("BIZ UDPGothic Regular"), "wasm={wasm}: {e}");
if !wasm {
assert!(e.contains("BIZ UDPGothic Bold"), "native builds need the weight pair: {e}");
}
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
}
}
/// Native required builds need the real BIZ Bold face for weight metrics; wasm deliberately
/// embeds only Regular to stay within its size budget. This pins the contract the engine's
/// required-build metric pair assertion relies on.
#[test]
fn required_native_builds_need_biz_bold_but_wasm_does_not() {
let fixture = InputFixture::new();
fixture.valid_latin();
fixture.optional(CHINESE_ROW);
fixture.main("BIZ UDPGothic | Regular | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n");
let e = input_fonts(Some(&fixture.0), false, true).unwrap_err();
assert!(e.contains("BIZ UDPGothic Bold"), "required native build accepted a Regular-only catalog: {e}");
let web = input_fonts(Some(&fixture.0), true, true).unwrap();
assert_eq!(web.matches("CraftFont {").count(), 3, "wasm keeps Inter Regular/Bold and BIZ Regular only");
}
/// Required checks accept either static Inter Regular+Bold or a single Variable face, together
/// with BIZ.
#[test]
fn required_builds_accept_static_or_variable_inter_with_biz() {
let fixture = InputFixture::new();
fixture.valid_latin();
fixture.optional(CHINESE_ROW);
for wasm in [true, false] {
let static_catalog = input_fonts(Some(&fixture.0), wasm, true).unwrap();
assert!(static_catalog.contains("Inter") && static_catalog.contains("BIZ UDPGothic"));
}
fixture.latin("Inter | Variable | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n");
for wasm in [true, false] {
let variable_catalog = input_fonts(Some(&fixture.0), wasm, true).unwrap();
assert!(variable_catalog.contains("Inter") && variable_catalog.contains("BIZ UDPGothic"));
}
}
}
+22
View File
@@ -409,16 +409,38 @@ mod tests {
assert!(feature("liga").is_some());
}
/// When the Latin build input exists, the runtime catalogue carries the expected Latin faces;
/// required builds also assert their presence.
#[test]
fn craft_fonts_latin_when_present() {
let required = option_env!("CRAFT_FONTS_REQUIRED").is_some();
if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") {
assert!(!required, "CRAFT_FONTS_REQUIRED must embed Inter rather than skip the optional Latin manifest");
eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)");
return;
}
let inter = |style: &str| CRAFT_FONTS.iter().find(|f| f.family == "Inter" && f.style == style);
let (regular, bold, variable) = (inter("Regular"), inter("Bold"), inter("Variable"));
if required {
assert!(
variable.is_some() || (regular.is_some() && bold.is_some()),
"CRAFT_FONTS_REQUIRED must embed Inter Regular and Bold (or a Variable face)"
);
}
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
assert_eq!(db.face("Inter", "Regular").family, "Inter");
assert_eq!(db.face("Aptos", "Regular").family, "Inter");
if let (Some(regular), Some(bold)) = (regular, bold) {
// The build input carries one entry per face; real static Bold must not be the Regular
// file relabelled in the manifest.
assert_ne!(regular.bytes, bold.bytes, "Inter Bold must be a distinct face file from Regular");
let (r, b) = (db.face("Inter", "Regular"), db.face("Inter", "Bold"));
assert_eq!((r.family.as_str(), r.style.as_str()), ("Inter", "Regular"));
assert_eq!((b.family.as_str(), b.style.as_str()), ("Inter", "Bold"));
assert_ne!(r.id(), b.id(), "Inter Bold must load as its own face");
assert!(b.weight > r.weight, "Inter Bold weight {} is not above Regular {}", b.weight, r.weight);
}
if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") {
assert_eq!(db.face("Calibri", "Bold").family, "Carlito");
}
+4
View File
@@ -0,0 +1,4 @@
//! Exercise the build-input parser without changing process environment variables.
#[allow(dead_code)]
#[path = "../build.rs"]
mod build_script;
+52
View File
@@ -0,0 +1,52 @@
# Synthetic paths; family/style inventory from craft-fonts 76ba5a0496503c0e2841abd626295038cbe07cde.
Inter | Regular | fonts/fixture.ttf | Latn
Inter | Italic | fonts/fixture.ttf | Latn
Inter | SemiBold | fonts/fixture.ttf | Latn
Inter | Bold | fonts/fixture.ttf | Latn
Carlito | Regular | fonts/fixture.ttf | Latn
Carlito | Bold | fonts/fixture.ttf | Latn
Carlito | Italic | fonts/fixture.ttf | Latn
Carlito | Bold Italic | fonts/fixture.ttf | Latn
Caladea | Regular | fonts/fixture.ttf | Latn
Caladea | Bold | fonts/fixture.ttf | Latn
Caladea | Italic | fonts/fixture.ttf | Latn
Caladea | Bold Italic | fonts/fixture.ttf | Latn
Liberation Sans | Regular | fonts/fixture.ttf | Latn
Liberation Sans | Bold | fonts/fixture.ttf | Latn
Liberation Sans | Italic | fonts/fixture.ttf | Latn
Liberation Sans | Bold Italic | fonts/fixture.ttf | Latn
Liberation Serif | Regular | fonts/fixture.ttf | Latn
Liberation Serif | Bold | fonts/fixture.ttf | Latn
Liberation Serif | Italic | fonts/fixture.ttf | Latn
Liberation Serif | Bold Italic | fonts/fixture.ttf | Latn
Liberation Mono | Regular | fonts/fixture.ttf | Latn
Liberation Mono | Bold | fonts/fixture.ttf | Latn
Liberation Mono | Italic | fonts/fixture.ttf | Latn
Liberation Mono | Bold Italic | fonts/fixture.ttf | Latn
Source Sans 3 | Regular | fonts/fixture.ttf | Latn
Source Sans 3 | Italic | fonts/fixture.ttf | Latn
Source Sans 3 | Semibold | fonts/fixture.ttf | Latn
Source Sans 3 | Bold | fonts/fixture.ttf | Latn
Source Serif 4 | Regular | fonts/fixture.ttf | Latn
Source Serif 4 | Italic | fonts/fixture.ttf | Latn
Source Serif 4 | Semibold | fonts/fixture.ttf | Latn
Source Serif 4 | Bold | fonts/fixture.ttf | Latn
Montserrat | Variable | fonts/fixture.ttf | Latn
Montserrat | Italic Variable | fonts/fixture.ttf | Latn
Lato | Regular | fonts/fixture.ttf | Latn
Lato | Bold | fonts/fixture.ttf | Latn
Lato | Italic | fonts/fixture.ttf | Latn
Open Sans | Variable | fonts/fixture.ttf | Latn
Open Sans | Italic Variable | fonts/fixture.ttf | Latn
Roboto | Variable | fonts/fixture.ttf | Latn
Roboto | Italic Variable | fonts/fixture.ttf | Latn
Merriweather | Regular | fonts/fixture.ttf | Latn
Merriweather | Bold | fonts/fixture.ttf | Latn
Merriweather | Italic | fonts/fixture.ttf | Latn
Playfair Display | Variable | fonts/fixture.ttf | Latn
Playfair Display | Italic Variable | fonts/fixture.ttf | Latn
Poppins | Regular | fonts/fixture.ttf | Latn
Poppins | Bold | fonts/fixture.ttf | Latn
Poppins | Italic | fonts/fixture.ttf | Latn
Nunito Sans | Variable | fonts/fixture.ttf | Latn
Nunito Sans | Italic Variable | fonts/fixture.ttf | Latn
+1566 -1566
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -18,15 +18,16 @@ deckcraft-render = { workspace = true }
deckcraft-anim = { workspace = true }
deckcraft-engine = { workspace = true }
deckcraft-media = { workspace = true }
egui = { workspace = true }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
web-time = { workspace = true }
[dev-dependencies]
skrifa = { workspace = true }
# kittest's own dev-dependencies do not propagate; tests need both font features explicitly.
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
[build-dependencies]
+65 -38
View File
@@ -153,7 +153,7 @@ pub enum ExportTask {
pub struct Backstage {
pub page: Page,
/// The open presentations (count, active) when it opened: a change closes it.
pub docs: (usize, Option<usize>),
pub docs: (usize, Option<u64>),
/// Keyboard focus in the side menu (an index into [`entries`], top then bottom).
pub focus: Option<usize>,
/// Search text on Home, New and Open.
@@ -180,7 +180,9 @@ pub struct Backstage {
}
impl Backstage {
pub fn new(page: Page, docs: (usize, Option<usize>)) -> Backstage {
/// Construct the Backstage view at a page with the current document identity, so a replaced deck
/// can be detected.
pub fn new(page: Page, docs: (usize, Option<u64>)) -> Backstage {
Backstage {
page,
docs,
@@ -337,7 +339,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
ui.spacing_mut().item_spacing.y = 0.0;
if has_doc {
let (r, resp) = ui.allocate_exact_size(vec2(NAV_W, BACK_H), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Back"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Back"));
let hovered = resp.hovered();
if hovered {
ui.painter().rect_filled(Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(34.0, 34.0)), 17.0, t.hover);
@@ -437,11 +439,12 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
// ---------------------------------------------------------------- side menu
/// Draw one clickable Backstage navigation row and return its interaction response.
fn nav_item(ui: &mut Ui, nav: Nav, bs: &Backstage, index: usize, t: &Tokens) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, ITEM_H), Sense::click());
let selected = nav == Nav::Page(bs.page);
let label = nav.label();
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label));
let p = ui.painter();
let row = rect.shrink2(vec2(6.0, 1.0));
if resp.hovered() {
@@ -495,7 +498,7 @@ fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: b
let t = Tokens::get(ui.ctx());
let thumb_w = if big { 200.0 } else { 160.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(thumb_w + 16.0, thumb_w * 9.0 / 16.0 + 44.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
let p = ui.painter();
if resp.hovered() {
p.rect_filled(rect, CornerRadius::same(4), t.hover);
@@ -599,7 +602,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
let missing = !app.recent_exists(&path);
let (name, folder) = split_path(&path);
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
let p = ui.painter();
if resp.hovered() {
p.rect_filled(rect, CornerRadius::same(3), t.hover);
@@ -625,7 +628,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)),
Sense::click(),
);
x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Remove from Recent"));
x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Remove from Recent"));
if x.hovered() {
ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover);
}
@@ -643,7 +646,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(88.0, 80.0), if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label));
let p = ui.painter();
p.rect_filled(rect, CornerRadius::same(3), if resp.hovered() && enabled { t.hover } else { t.card });
p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.0, if resp.hovered() && enabled { t.accent } else { t.border }), StrokeKind::Inside);
@@ -658,7 +661,7 @@ fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::R
fn place_row(ui: &mut Ui, icon: Icon, label: &str, selected: bool, width: f32) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label));
let p = ui.painter();
if selected {
p.rect_filled(rect, CornerRadius::same(2), t.selected);
@@ -936,13 +939,18 @@ fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom
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").width(340.0).selected_text(current).show_ui(ui, |ui| {
for (id, label, _) in SAVE_TYPES {
if ui.selectable_label(bs.file_type == id, label).clicked() {
bs.file_type = id;
egui::ComboBox::from_id_salt("backstage_save_type")
.width(340.0)
.selected_text(current)
.show_ui(ui, |ui| {
for (id, label, _) in SAVE_TYPES {
if ui.selectable_label(bs.file_type == id, label).clicked() {
bs.file_type = id;
}
}
}
});
})
.response
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type"));
let web = app.services.pick_save.is_none();
let label = if web { "Download" } else { "Save" };
let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0)));
@@ -1057,7 +1065,7 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
PrintScope::Current => "Print Current Slide",
PrintScope::Custom => "Custom Range",
};
egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| {
let combo = egui::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")]
{
@@ -1067,6 +1075,7 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
}
}
});
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print range"));
ui.add_space(4.0);
ui.horizontal(|ui| {
let l = ui.label("Slides:");
@@ -1079,7 +1088,7 @@ 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);
egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| {
let combo = egui::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;
@@ -1087,27 +1096,38 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
}
}
});
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print layout"));
ui.add_space(4.0);
// PowerPoint prints full-page slides on slide-shaped pages; paper and orientation are
// 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").width(240.0).selected_text(paper).show_ui(ui, |ui| {
for (id, label, _) in deckcraft_engine::cmd::file::PAPERS {
if ui.selectable_label(bs.print_paper == id, label).clicked() {
bs.print_paper = id;
egui::ComboBox::from_id_salt("backstage_print_paper")
.width(240.0)
.selected_text(paper)
.show_ui(ui, |ui| {
for (id, label, _) in deckcraft_engine::cmd::file::PAPERS {
if ui.selectable_label(bs.print_paper == id, label).clicked() {
bs.print_paper = id;
}
}
}
});
})
.response
.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").width(240.0).selected_text(orient).show_ui(ui, |ui| {
for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] {
if ui.selectable_label(bs.print_landscape == land, label).clicked() {
bs.print_landscape = land;
egui::ComboBox::from_id_salt("backstage_print_orientation")
.width(240.0)
.selected_text(orient)
.show_ui(ui, |ui| {
for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] {
if ui.selectable_label(bs.print_landscape == land, label).clicked() {
bs.print_landscape = land;
}
}
}
});
})
.response
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Orientation"));
});
ui.add_space(4.0);
if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() {
@@ -1230,10 +1250,11 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_
});
}
/// Draw a labelled icon button and return whether it was clicked while enabled.
fn icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label));
if resp.hovered() && enabled {
ui.painter().rect_filled(r, CornerRadius::same(3), t.hover);
}
@@ -1280,7 +1301,7 @@ 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"]);
egui::ComboBox::from_id_salt("backstage_handouts")
let combo = egui::ComboBox::from_id_salt("backstage_handouts")
.width(240.0)
.selected_text(format!("{} slides per page", bs.handouts_per_page))
.show_ui(ui, |ui| {
@@ -1290,6 +1311,7 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
}
}
});
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Slides per page"));
ui.add_space(10.0);
if square_button(ui, Icon::Notes, "Create Handouts", true).clicked() {
app.export_as("pdf", json!({"layout": "handouts", "perPage": bs.handouts_per_page}));
@@ -1301,7 +1323,7 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
for (id, label, detail) in SAVE_TYPES.iter().filter(|s| s.0 != "pdf") {
let selected = bs.file_type == *id;
let (rect, resp) = ui.allocate_exact_size(vec2(520.0, 46.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, *label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, *label));
let p = ui.painter();
if selected {
p.rect_filled(rect, CornerRadius::same(2), t.selected);
@@ -1351,13 +1373,18 @@ 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").width(260.0).selected_text(if dark { "Dark" } else { "Light" }).show_ui(ui, |ui| {
for (on, label) in [(false, "Light"), (true, "Dark")] {
if ui.selectable_label(dark == on, label).clicked() {
let _ = app.run("view.dark", json!({"on": on}));
egui::ComboBox::from_id_salt("backstage_appearance")
.width(260.0)
.selected_text(if dark { "Dark" } else { "Light" })
.show_ui(ui, |ui| {
for (on, label) in [(false, "Light"), (true, "Dark")] {
if ui.selectable_label(dark == on, label).clicked() {
let _ = app.run("view.dark", json!({"on": on}));
}
}
}
});
})
.response
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance"));
ui.add_space(16.0);
heading(ui, "User Information");
dim(ui, "DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device.");
+95
View File
@@ -456,6 +456,101 @@ fn the_start_screen_is_file_home() {
assert!(h.state().backstage.is_none());
}
/// The start screen is not a hidden slide: typing, pasting and IME events must not create or
/// edit a presentation while the File-tab navigation keys still work.
#[test]
fn start_screen_ignores_text_paste_and_ime_while_file_keys_navigate() {
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
assert_eq!(page(&h), Some(Page::Home));
h.input_mut().events.extend([
egui::Event::Text("x".into()),
egui::Event::Paste("pasted".into()),
egui::Event::Ime(egui::ImeEvent::Preedit { text: "あ".into(), active_range_chars: None }),
egui::Event::Ime(egui::ImeEvent::Commit("あ".into())),
egui::Event::Key { key: Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::default() },
]);
h.run_steps(2);
assert!(h.state().session.active().is_none(), "no typing or IME event opened a presentation");
assert!(h.state().session.documents().is_empty(), "the start screen has no slide to edit");
assert!(h.state().status.is_none(), "typing, paste and IME were ignored, not run and refused");
assert_eq!(page(&h), Some(Page::New), "the File-tab arrow still navigates");
assert!(h.output().platform_output.ime.is_none(), "the start screen publishes no input-method descriptor");
}
/// The start screen keeps the shortcuts that open something in the app: the command palette and
/// Preferences. Both are in-app windows, so no native dialog is involved.
#[test]
fn start_screen_opens_the_palette_and_preferences() {
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::P);
h.run_steps(3);
assert!(h.state().palette.is_some(), "Ctrl+Shift+P opens the command palette");
assert!(h.state().session.active().is_none(), "without creating a presentation");
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
h.key_press_modifiers(Modifiers::COMMAND, Key::Comma);
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"), "Ctrl+, opens Preferences");
assert!(h.state().session.active().is_none(), "without creating a presentation");
}
/// Ctrl+O still asks the app's open dialog on the start screen, and Alt+F opens the File tab with
/// the keyboard in its side menu. The open dialog is a mock service, not the host's picker.
#[test]
fn start_screen_opens_files_and_the_file_tab_from_the_keyboard() {
let opens: Rc<RefCell<Vec<String>>> = Default::default();
let log = opens.clone();
let services = Services {
pick_open: Some(Box::new(move |purpose: &str| {
log.borrow_mut().push(purpose.to_string());
None
})),
..Default::default()
};
let mut h = harness(SlideApp::new(Session::new(), services));
h.key_press_modifiers(Modifiers::COMMAND, Key::O);
h.run_steps(3);
let asked = opens.borrow();
assert_eq!(asked.len(), 1, "Ctrl+O asks the app's open dialog once");
assert_eq!(asked[0], "open", "Ctrl+O reaches the app's open dialog");
assert!(h.state().session.active().is_none(), "a cancelled open leaves the start screen");
assert_eq!(page(&h), Some(Page::Home));
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), None);
h.key_press_modifiers(Modifiers::ALT, Key::F);
h.run_steps(3);
assert_eq!(page(&h), Some(Page::Home), "Alt+F opens the File tab on Home");
assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), Some(0), "with the keyboard in the side menu");
}
/// Document commands stay off the empty start screen: no save picker, find palette, hyperlink
/// dialog, close prompt, status error or page change.
#[test]
fn start_screen_ignores_document_shortcuts() {
let saves: Rc<RefCell<Vec<String>>> = Default::default();
let log = saves.clone();
let services = Services {
pick_save: Some(Box::new(move |suggested: &str| {
log.borrow_mut().push(suggested.to_string());
None
})),
..Default::default()
};
let mut h = harness(SlideApp::new(Session::new(), services));
for (mods, key) in [(Modifiers::COMMAND, Key::S), (Modifiers::COMMAND, Key::K), (Modifiers::COMMAND, Key::F), (Modifiers::COMMAND, Key::W)] {
h.key_press_modifiers(mods, key);
h.run_steps(2);
assert!(h.state().session.active().is_none(), "{key:?} did not open a presentation");
assert!(h.state().session.documents().is_empty(), "{key:?} created no document");
assert_eq!(page(&h), Some(Page::Home), "{key:?} left the start screen in place");
assert!(h.state().status.is_none(), "{key:?} was ignored rather than run and refused");
assert!(h.state().dialog.is_none(), "{key:?} opened no document dialog");
assert!(h.state().palette.is_none(), "{key:?} opened no find palette");
}
assert!(saves.borrow().is_empty(), "Ctrl+S did not ask for a file");
}
/// #346: a file picked in Open that can't be read used to drop back to the start screen without
/// a word. The reason now shows over the start screen.
#[test]
+34 -15
View File
@@ -41,6 +41,12 @@ fn mods(ui: &Ui) -> Mods {
/// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom
/// and two-finger scrolling are left as they are.
pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) {
if ctx.will_discard() {
// Multi-pass layout runs this function again with the input already consumed. Acting on
// the discarded pass would start the twist and the replay would immediately end it,
// splitting one gesture into two undo steps.
return;
}
let delta = ctx.input(|i| i.rotation_delta());
let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 };
let turning = delta.abs() > 1e-4;
@@ -137,6 +143,8 @@ fn trackpad_scroll(events: &[egui::Event]) -> egui::Vec2 {
})
}
/// Paint the editing canvas, including rulers, selection handles, overlays, and current
/// zoom/scroll state.
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let avail = ui.available_rect_before_wrap();
@@ -144,7 +152,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let Some(st) = app.session.active() else { return };
// The leftover fraction of a wheel line belongs to one presentation. Drop it whenever another
// one is drawn here, hovered or not, so switching away and back starts from zero.
let carry_id = ui.id().with("wheel_carry");
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) {
@@ -160,7 +168,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
return;
}
// Scroll when zoomed beyond the window.
let scroll_id = ui.id().with("canvas_scroll");
let scroll_id = ui.scope_id().with("canvas_scroll");
let last_id = scroll_id.with("last");
let mut offset: egui::Vec2 = ui.data_mut(|d| d.get_temp(scroll_id).unwrap_or_default());
let last: Option<(Rect, f32)> = ui.data_mut(|d| d.get_temp(last_id));
@@ -241,7 +249,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
} 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.id().with("wheel_gate");
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 {
@@ -454,17 +462,18 @@ pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
/// Turn the input method on for the slide while it takes typing, with its candidate window at
/// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
///
/// This only records the screen rect for this pass. [`crate::SlideApp`] observes the committed
/// text, owner and cause once at the end of the pass, after every IME event and editor command
/// has settled.
fn ime_output(app: &mut SlideApp, ui: &Ui, xf: Xf) {
let ctx = ui.ctx();
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.backstage_open() {
return;
}
let Some(r) = ime_area(app, xf) else { return };
let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default();
let r = to_global * r;
ctx.output_mut(|o| {
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false });
});
app.ime_pending = Some(to_global * r);
}
/// Play/pause on an animated GIF under the pointer or selected on its own.
@@ -483,7 +492,8 @@ fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gi
}
let b = crate::media::gif_button_rect(r);
// Sensing drags too keeps the press from reaching the canvas (selecting or moving the GIF).
let resp = ui.interact(b, ui.id().with(("gif_button", g.id.0)), Sense::click_and_drag());
let resp = ui.interact(b, ui.scope_id().with(("gif_button", g.id.0)), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if g.paused { "Play animated GIF" } else { "Pause animated GIF" }));
crate::media::paint_gif_button(ui.painter(), b, g.paused, resp.hovered());
if resp.clicked() {
toggle = Some(g.id);
@@ -649,8 +659,9 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect
let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top);
p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside);
let resp = ui.interact(top, ui.id().with("ruler_markers"), Sense::click_and_drag());
let id = ui.id().with("ruler_drag");
let resp = ui.interact(top, ui.scope_id().with("ruler_markers"), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Text ruler"));
let id = ui.scope_id().with("ruler_drag");
let mut drag: Option<RulerDrag> = ui.data(|d| d.get_temp::<RulerDrag>(id));
let pointer = resp.interact_pointer_pos().or(resp.hover_pos());
if resp.drag_started()
@@ -716,17 +727,17 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect
}
// The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click
// on the ruler then sets that kind of stop.
let kind_id = ui.id().with("ruler_tab_kind");
let kind_id = ui.scope_id().with("ruler_tab_kind");
let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0);
let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0));
let cr = ui.interact(corner, ui.id().with("ruler_tab_selector"), Sense::click());
let cr = ui.interact(corner, ui.scope_id().with("ruler_tab_selector"), Sense::click());
const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")];
if cr.clicked() {
kind = (kind + 1) % KINDS.len();
ui.data_mut(|d| d.insert_temp(kind_id, kind));
}
let (kind_align, kind_label) = KINDS[kind % KINDS.len()];
cr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, kind_label));
cr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, kind_label));
let cr = cr.on_hover_text(format!("{kind_label} (click to change)"));
p.rect_stroke(
corner.shrink(2.0),
@@ -1709,8 +1720,16 @@ mod tests {
pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s)
}
/// Test helper: inject a platform-command-modified line wheel event and run thirty harness
/// steps.
fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) {
let wheel = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: TouchPhase::Move, modifiers: Modifiers::COMMAND };
let wheel = Event::MouseWheel {
unit: MouseWheelUnit::Line,
delta: vec2(0.0, lines),
phase: TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
modifiers: Modifiers::COMMAND,
};
h.event(wheel);
// egui spreads a wheel notch over several frames.
h.run_steps(30);
File diff suppressed because it is too large Load Diff
+69 -12
View File
@@ -21,6 +21,7 @@ use deckcraft_engine::{Mods, PointerEvent};
use serde_json::{Value, json};
use crate::SlideApp;
use crate::capture::{MAX_IMAGE_BYTES, MAX_TOTAL_BYTES, MemoryPlan};
pub type ControlResponse = Value;
@@ -132,6 +133,7 @@ fn f(p: &Value, k: &str) -> Option<f32> {
p.get(k).and_then(Value::as_f64).map(|v| v as f32)
}
/// Dispatch one decoded control request and return an outcome for its caller to reply with.
pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
@@ -173,8 +175,8 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
&& !show.ended
&& let Some(key) = key_from(k)
{
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
app.queue_input(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
app.queue_input(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
return ok(json!({"show": true}));
}
wrap(app.session.key(k, mods_from(p)).map_err(|e| e.to_string()))
@@ -197,7 +199,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
} else {
return err("missing `preedit` or `commit`");
};
app.synthetic.push(egui::Event::Ime(ev));
app.queue_input(egui::Event::Ime(ev));
ok(json!({"queued": true}))
}
"ui.tool.select" => {
@@ -213,7 +215,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
"ui.move" | "ui.click" | "ui.drag" => {
let (Some(x), Some(y)) = (f(p, "x"), f(p, "y")) else { return err("missing x/y") };
let pos = egui::pos2(x, y);
app.synthetic.push(egui::Event::PointerMoved(pos));
app.queue_input(egui::Event::PointerMoved(pos));
if req.method == "ui.click" {
let button = match s("button") {
Some("right" | "secondary") => egui::PointerButton::Secondary,
@@ -221,13 +223,13 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
};
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3);
for _ in 0..count {
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: egui::Modifiers::NONE });
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE });
app.queue_input(egui::Event::PointerButton { pos, button, pressed: true, modifiers: egui::Modifiers::NONE });
app.queue_input(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE });
}
} else if req.method == "ui.drag" {
let (Some(tx), Some(ty)) = (f(p, "toX"), f(p, "toY")) else { return err("missing toX/toY") };
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 120);
app.synthetic.push(egui::Event::PointerButton {
app.queue_input(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
@@ -235,9 +237,9 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
});
for k in 1..=steps {
let a = k as f32 / steps as f32;
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * a, y + (ty - y) * a)));
app.queue_input(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * a, y + (ty - y) * a)));
}
app.synthetic.push(egui::Event::PointerButton {
app.queue_input(egui::Event::PointerButton {
pos: egui::pos2(tx, ty),
button: egui::PointerButton::Primary,
pressed: false,
@@ -245,7 +247,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
});
}
ctx.request_repaint();
ok(json!({"queued": app.synthetic.len()}))
ok(json!({"queued": app.synthetic_queued_len()}))
}
"ui.set" => {
if let Some(t) = s("tab") {
@@ -346,7 +348,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
}
"show.end" => {
if app.show.is_some() {
app.synthetic.push(egui::Event::Key {
app.queue_input(egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
@@ -409,11 +411,38 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option<deckcraft_engine::T
})
}
/// Encode and write a screenshot, or return inline base64 data, while enforcing the capture
/// budgets and reporting encoding and write errors.
pub fn save_screenshot(app: &mut SlideApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
let [w, h] = image.size;
let inline = path.is_none();
// The request was reserved from the same geometry; re-check the exact image before making
// the RGBA/PNG/base64 copies so a mismatched or oversized completion cannot allocate past
// the capture budget. A path reply deliberately never builds the base64 text.
let Some(plan) = MemoryPlan::for_image(w, h, inline) else {
return json!({"ok": false, "error": "the screenshot has no usable image size"});
};
if plan.color_image > MAX_IMAGE_BYTES {
return json!({"ok": false, "error": "the screenshot would exceed 128 MiB; use ui.render"});
}
if plan.peak > MAX_TOTAL_BYTES {
return json!({"ok": false, "error": "the screenshot would exceed the 256 MiB capture budget; use ui.render"});
}
if image.pixels.len().checked_mul(4) != Some(plan.color_image) {
return json!({"ok": false, "error": "the screenshot pixel data does not match its size"});
}
let Some((width, height)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()) else {
return json!({"ok": false, "error": "the screenshot has no usable image size"});
};
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
let img = deckcraft_render::Image { width: w as u32, height: h as u32, pixels: rgba };
let img = deckcraft_render::Image { width, height, pixels: rgba };
let png = img.to_png();
if png.is_empty() {
return json!({"ok": false, "error": "the screenshot could not be encoded"});
}
if png.len() > plan.png {
return json!({"ok": false, "error": "the screenshot PNG exceeded its reserved budget"});
}
let Some(path) = path else {
return json!({"ok": true, "result": {"width": w, "height": h, "png": deckcraft_engine::cmd::base64_encode(&png)}});
};
@@ -432,6 +461,34 @@ mod tests {
use super::*;
/// A path reply writes the PNG and returns no base64 text; an inline reply returns PNG
/// bytes base64-encoded once. Both go through the same checked memory plan.
#[test]
fn screenshot_reply_is_path_only_or_explicitly_inline() {
use std::sync::{Arc, Mutex};
let mut app = SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default());
let written = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&written);
app.services.write = Some(Box::new(move |_path, bytes| {
*sink.lock().unwrap() = bytes.to_vec();
Ok(())
}));
let image = egui::ColorImage::filled([2, 2], egui::Color32::from_rgb(1, 2, 3));
let path_reply = save_screenshot(&mut app, &image, Some("/tmp/shot.png"));
assert_eq!(path_reply["ok"], true, "{path_reply}");
assert!(path_reply["result"].get("png").is_none(), "a path reply never builds base64");
let png = written.lock().unwrap().clone();
assert_eq!(png.get(..8), Some(&b"\x89PNG\r\n\x1a\n"[..]), "the writer gets a PNG");
let inline = save_screenshot(&mut app, &image, None);
assert_eq!(inline["ok"], true, "{inline}");
let text = inline["result"]["png"].as_str().expect("inline base64");
let decoded = deckcraft_engine::cmd::base64_decode(text).expect("valid base64");
assert_eq!(decoded, png, "the inline reply is the same PNG");
}
/// `take` and `abandon` are exclusive: whoever wins, the request runs at most once (#719).
#[test]
fn take_and_abandon_are_exclusive() {
+4 -3
View File
@@ -212,7 +212,7 @@ impl Default for View {
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let id = egui::Id::unique("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label("Show");
@@ -222,8 +222,8 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
}
}
ui.separator();
ui.label("Sort");
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
let field = ui.label("Sort");
let combo = egui::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;
@@ -231,6 +231,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
}
}
});
let _ = combo.response.labelled_by(field.id);
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() {
v.ascending = !v.ascending;
}
+2 -1
View File
@@ -39,6 +39,7 @@ fn date_run(h: &Harness<'_, SlideApp>) -> Option<deckcraft_model::Run> {
.cloned()
}
/// Insert opens date/time settings, and OK inserts exactly one field.
#[test]
fn insert_opens_the_dialog_and_ok_inserts_one_field() {
let mut h = harness();
@@ -46,7 +47,7 @@ fn insert_opens_the_dialog_and_ok_inserts_one_field() {
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("dateTime"));
let _ = h.get_by_label("Update automatically");
let _ = h.get_by_label("English (United States)");
assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Language").value().as_deref(), Some("English (United States)"));
let now = deckcraft_text::datetime::DateTime::now_local().unwrap();
let sample = deckcraft_text::datetime::field_text("datetime2", Some("en-US"), now).unwrap();
h.get_by_label(&sample).click();
+109 -60
View File
@@ -77,12 +77,14 @@ fn title(id: &str) -> &'static str {
}
}
/// Render the active dialog, clearing it when dismissed and preserving or restoring application
/// state as its close path requires.
pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
let mut open = true;
let mut close = false;
let mut window = egui::Window::new(title(&d.id))
.id(egui::Id::new(("dialog", d.id.clone())))
.id(egui::Id::unique(("dialog", d.id.clone())))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0))
@@ -182,11 +184,13 @@ fn restore_preferences(app: &mut SlideApp, d: &Dialog) {
}
}
/// Draw a labelled single-line field bound to `key`, falling back to `default`, and store the
/// edited value in the dialog.
fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) {
let mut v = d.get(key, default);
ui.horizontal(|ui| {
ui.add_sized(vec2(120.0, 20.0), egui::Label::new(label));
ui.add(egui::TextEdit::singleline(&mut v).desired_width(180.0));
let l = ui.add_sized(vec2(120.0, 20.0), egui::Label::new(label));
ui.add(egui::TextEdit::singleline(&mut v).desired_width(180.0)).labelled_by(l.id);
});
d.fields.insert(key.into(), v);
}
@@ -278,7 +282,9 @@ fn pick_album_picture(app: &mut SlideApp) -> Option<Value> {
Some(json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&loaded)}))
}
fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, key: &str, default: &str, choices: &[(&str, &str)]) {
/// Draw an accessible album combo box, preserving its current/default selection and storing a
/// newly chosen key; the caller supplies the visible label.
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| {
@@ -288,7 +294,13 @@ fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, key: &str, default: &str,
}
}
});
opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label));
let access = name.to_string();
let shown = label.to_string();
opened.response.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, access.clone());
info.current_text_value = Some(shown.clone());
info
});
}
const ALBUM_THEMES: &[&str] = &["DeckCraft", "Harbor", "Ember", "Meadow", "Nocturne", "Paper", "Coral Reef", "Slate"];
@@ -458,6 +470,7 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui,
d,
"album_layout",
"Picture layout",
"layout",
"1",
&[
@@ -475,6 +488,7 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui,
d,
"album_frame",
"Frame shape",
"frame",
"rect",
&[("none", "None"), ("rect", "Rectangle"), ("rounded", "Rounded"), ("simple", "Simple, white")],
@@ -482,7 +496,7 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
check(ui, d, "Captions under pictures", "captions", false);
check(ui, d, "ALL pictures black and white", "grayscale", false);
ui.label("Album theme");
album_choice(ui, d, "album_theme", "theme", "DeckCraft", &ALBUM_THEMES.iter().map(|n| (*n, *n)).collect::<Vec<_>>());
album_choice(ui, d, "album_theme", "Album theme", "theme", "DeckCraft", &ALBUM_THEMES.iter().map(|n| (*n, *n)).collect::<Vec<_>>());
});
let captions = d.fields.get("captions").is_some_and(|v| v == "1");
let grayscale = d.fields.get("grayscale").is_some_and(|v| v == "1");
@@ -556,6 +570,7 @@ fn seed_theme_colors(d: &mut Dialog) {
d.fields.insert("seeded".into(), "1".into());
}
/// Draw one theme-colour row with its hex editor and store the value for the scheme slot.
fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) {
let key = slot.xml_name();
let mut v = d.get(key, "000000");
@@ -567,7 +582,7 @@ fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) {
let resp = ui.add(egui::TextEdit::singleline(&mut v).desired_width(84.0).id_salt(("theme-hex", key)));
let shown = v.clone();
resp.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, format!("{} hex", slot.label()));
let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, format!("{} hex", slot.label()));
info.current_text_value = Some(shown.clone());
info
});
@@ -615,7 +630,7 @@ fn theme_colors_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool
let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(280.0).id_salt("schemeName"));
let shown = name.clone();
resp.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Scheme name");
let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, "Scheme name");
info.current_text_value = Some(shown.clone());
info
});
@@ -662,6 +677,8 @@ fn theme_font_choices(app: &SlideApp, selected: &str) -> Vec<String> {
v
}
/// 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| {
for face in choices {
@@ -673,7 +690,7 @@ fn font_combo(ui: &mut Ui, label: &str, choices: &[String], selected: &mut Strin
let shown = selected.clone();
let access = label.to_string();
resp.response.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, access.clone());
let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, access.clone());
info.current_text_value = Some(shown.clone());
info
});
@@ -726,7 +743,7 @@ fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0).id_salt("schemeName"));
let shown = name.clone();
resp.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Scheme name");
let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, "Scheme name");
info.current_text_value = Some(shown.clone());
info
});
@@ -961,8 +978,8 @@ fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
ui.horizontal(|ui| {
ui.label("Percent:");
let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0));
let l = ui.label("Percent:");
let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0)).labelled_by(l.id);
if edit.changed() {
mode = "percent".into();
}
@@ -1056,9 +1073,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
"slideSize" => {
let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE);
ui.label("Slides sized for:");
let label = ui.label("Slides sized for:");
let preset = d.get("preset", "");
egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| {
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());
@@ -1067,6 +1084,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
});
let _ = combo.response.labelled_by(label.id);
field(ui, d, "Width (in):", "w", &format!("{:.2}", cur.width / 72.0));
field(ui, d, "Height (in):", "h", &format!("{:.2}", cur.height / 72.0));
let (w, h) = (num(d, "w", cur.width / 72.0), num(d, "h", cur.height / 72.0));
@@ -1213,9 +1231,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings);
let names: Vec<String> = 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();
ui.label("Slides");
let field = ui.label("Slides");
let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() };
egui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| {
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());
}
@@ -1225,6 +1243,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
});
let _ = combo.response.labelled_by(field.id);
let (ok, cancel) = buttons(ui, "OK");
if ok {
let ty = d.get("type", "speaker");
@@ -1303,7 +1322,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
"zoom" => zoom_dialog(app, ui, d),
"comment" => {
let mut v = d.get("text", "");
ui.add(egui::TextEdit::multiline(&mut v).hint_text("Start a conversation").desired_rows(4).desired_width(320.0));
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() {
@@ -1372,8 +1391,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
];
let color = d.get("color", "");
ui.horizontal(|ui| {
ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:"));
egui::ComboBox::from_id_salt("bullet-color")
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 {
@@ -1382,6 +1401,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
});
let _ = combo.response.labelled_by(field.id);
});
ui.horizontal(|ui| {
if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() {
@@ -1461,7 +1481,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
};
pick(d, ui, "none", "None");
ui.horizontal(|ui| {
if ui.radio(hyperlink, "Hyperlink to:").clicked() && !hyperlink {
let field = ui.radio(hyperlink, "Hyperlink to:");
if field.clicked() && !hyperlink {
d.fields.insert("kind".into(), "next".into());
}
let targets = [
@@ -1477,13 +1498,14 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
];
let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1);
ui.add_enabled_ui(hyperlink, |ui| {
egui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |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);
@@ -1547,7 +1569,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
.unwrap_or_default();
ui.label("How would you describe this object and its context to someone who is blind or has low vision?");
let mut v = d.get("text", &cur);
ui.add(egui::TextEdit::multiline(&mut v).desired_rows(4).desired_width(340.0));
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");
@@ -1559,7 +1581,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
"outline" => {
ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets.");
let mut v = d.get("text", "");
ui.add(egui::TextEdit::multiline(&mut v).desired_rows(10).desired_width(380.0).code_editor());
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 {
@@ -1665,8 +1687,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
if fmt == "pdf" {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Print layout:");
egui::ComboBox::from_id_salt("pdf_layout")
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)",
@@ -1693,6 +1715,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
});
let _ = combo.response.labelled_by(field.id);
});
let mut hidden = d.get("pdfHidden", "false") == "true";
if ui.checkbox(&mut hidden, "Include hidden slides").changed() {
@@ -1749,12 +1772,13 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
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| {
ui.label("Replace:");
egui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(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| {
@@ -1818,7 +1842,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
"equation" => {
ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a).");
let mut v = d.get("eq", "");
ui.add(egui::TextEdit::singleline(&mut v).desired_width(340.0).font(theme::font(15.0)));
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() {
@@ -2037,7 +2061,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
// 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 (name, tag) in all {
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() {
@@ -2111,7 +2138,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
egui::Grid::new("chartdata").striped(true).show(ui, |ui| {
for row in grid.iter_mut() {
for cell in row.iter_mut() {
ui.add(egui::TextEdit::singleline(cell).desired_width(70.0));
ui.add(egui::TextEdit::singleline(cell).hint_text("Chart cell").desired_width(70.0));
}
ui.end_row();
}
@@ -2176,7 +2203,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
});
let mut v = d.get("text", "Plan\nBuild\nLaunch");
ui.add(egui::TextEdit::multiline(&mut v).desired_rows(5).desired_width(320.0));
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 {
@@ -2218,10 +2245,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let mut from = d.get("from", "");
let mut to = d.get("to", "");
ui.horizontal(|ui| {
ui.label("Replace");
ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0));
ui.label("With");
ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0));
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();
@@ -2266,7 +2293,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
// on release, and at once from the keyboard.
let pending = d.fields.get("uiScale").and_then(|v| v.parse::<f32>().ok());
let mut scale = pending.unwrap_or(app.ui.ui_scale);
let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size"));
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());
@@ -2291,8 +2321,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
return false;
}
ui.horizontal(|ui| {
ui.label("Your name (comments):");
ui.text_edit_singleline(&mut app.session.prefs.author);
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 {
@@ -2514,11 +2546,12 @@ fn size_position_value(d: &Dialog) -> Result<Value, String> {
Ok(json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "lockAspect": field_of(d, "lock") == "1"}))
}
/// Draw a labelled single-line text row bound to a dialog key and store the edited value.
fn text_row(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str) {
let mut v = field_of(d, key);
ui.horizontal(|ui| {
ui.add_sized(vec2(148.0, 20.0), egui::Label::new(label));
ui.add(egui::TextEdit::singleline(&mut v).desired_width(96.0));
let l = ui.add_sized(vec2(148.0, 20.0), egui::Label::new(label));
ui.add(egui::TextEdit::singleline(&mut v).desired_width(96.0)).labelled_by(l.id);
});
d.fields.insert(key.into(), v);
}
@@ -2579,6 +2612,8 @@ fn date_rows(lang: &str) -> Vec<(&'static str, String)> {
rows
}
/// Render language and format controls; OK applies `insert.dateTime`, and the return value says
/// whether the dialog should close.
fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.set_min_width(440.0);
ui.label("Available formats:");
@@ -2607,7 +2642,12 @@ fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
});
opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, lang_label));
let shown = lang_label.to_string();
opened.response.widget_info(move || {
let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Language");
info.current_text_value = Some(shown.clone());
info
});
});
ui.label(
egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.")
@@ -2876,16 +2916,19 @@ fn seed_font_fields(d: &mut Dialog) {
seed_once(d, "colorSeed", shown);
}
fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, key: &str, choices: &[(&str, &str)]) {
/// Draw a uniquely identified accessible choice combo box and store the selected key;
/// the caller supplies the visible label.
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());
egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| {
let combo = egui::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());
}
}
});
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, name));
}
fn labeled_text(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, width: f32) -> String {
@@ -2910,10 +2953,11 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
fams.insert(0, family.clone());
}
ui.horizontal(|ui| {
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font"));
egui::ComboBox::from_id_salt("font_dialog_family").selected_text(if family.is_empty() { " " } else { family.as_str() }).width(300.0).show_ui(
ui,
|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")
.selected_text(if family.is_empty() { " " } else { family.as_str() })
.width(300.0)
.show_ui(ui, |ui| {
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
for f in &fams {
if ui.selectable_label(family == *f, f).clicked() {
@@ -2921,8 +2965,8 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
});
},
);
});
let _ = combo.response.labelled_by(field.id);
});
ui.horizontal(|ui| {
let _ = labeled_text(ui, d, "Size", "size", 64.0);
@@ -2936,6 +2980,7 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui,
d,
"font_dialog_underline",
"Underline",
"underline",
&[("none", "None"), ("sng", "Single"), ("dbl", "Double"), ("heavy", "Heavy"), ("dotted", "Dotted"), ("dash", "Dash"), ("wavy", "Wavy")],
);
@@ -2944,21 +2989,21 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
ui.horizontal(|ui| {
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough"));
choice(ui, d, "font_dialog_strike", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]);
choice(ui, d, "font_dialog_strike", "Strikethrough", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]);
});
ui.horizontal(|ui| {
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline"));
choice(ui, d, "font_dialog_baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]);
choice(ui, d, "font_dialog_baseline", "Baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]);
ui.add_space(8.0);
ui.label("Caps");
choice(ui, d, "font_dialog_caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]);
choice(ui, d, "font_dialog_caps", "Caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]);
});
ui.horizontal(|ui| {
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing"));
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;
egui::ComboBox::from_id_salt("font_dialog_spacing")
let combo = egui::ComboBox::from_id_salt("font_dialog_spacing")
.selected_text(match spacing.as_str() {
"-3" => "Very tight",
"-1.5" => "Tight",
@@ -2975,6 +3020,7 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
});
combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Spacing"));
let _ = edited;
if let Some(v) = picked {
d.fields.insert("spacing".into(), v);
@@ -3117,6 +3163,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui,
d,
"paragraph_align",
"Alignment",
"align",
&[
("left", "Left"),
@@ -3138,7 +3185,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special"));
choice(ui, d, "paragraph_special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]);
choice(ui, d, "paragraph_special", "Special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]);
ui.add_space(8.0);
let _ = labeled_text(ui, d, "By", "by", 64.0);
ui.label("in");
@@ -3153,10 +3200,10 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
let mut picked: Option<(&'static str, Option<&'static str>)> = None;
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing"));
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");
egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| {
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")),
@@ -3169,6 +3216,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
}
});
let _ = combo.response.labelled_by(field.id);
ui.add_space(8.0);
let _ = labeled_text(ui, d, "At", "lineValue", 64.0);
});
@@ -3295,9 +3343,9 @@ fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Tab stop position (in):");
let l = ui.label("Tab stop position (in):");
let mut v = d.get("newpos", "");
ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0));
ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)).labelled_by(l.id);
d.fields.insert("newpos".into(), v);
});
let cur = d.get("newalign", "l");
@@ -3459,14 +3507,15 @@ fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result<Value, Str
};
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:"));
egui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |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| {
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());
}
}
});
let _ = combo.response.labelled_by(field.id);
});
if size == "custom" {
field(ui, d, "Width (pixels):", "gifWidth", "960");
+2 -1
View File
@@ -39,8 +39,9 @@ pub struct State {
image: deckcraft_render::Image,
}
/// Derive a unique egui id for an eyedropper menu target name.
fn key(name: &str) -> Id {
Id::new(name)
Id::unique(name)
}
/// The colour menu button whose menu is open (noted by `color_popup` every frame it is).
+2 -1
View File
@@ -26,8 +26,9 @@ fn harness() -> Harness<'static, SlideApp> {
h
}
/// Test helper: stable unique id for the eyedropper test menu.
fn menu() -> Id {
Id::new("test-menu")
Id::unique("test-menu")
}
/// A colour menu asked for the Eyedropper.
+15 -12
View File
@@ -107,7 +107,7 @@ const DIRECTIONS_PATH: [(&str, [f64; 4]); 5] = [
pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> Option<Gradient> {
let t = Tokens::get(ui.ctx());
let mut out: Option<Gradient> = None;
let sel_id = ui.id().with((id, "stop"));
let sel_id = ui.scope_id().with((id, "stop"));
let mut sel: usize = ui.data_mut(|d| d.get_temp(sel_id).unwrap_or(0)).min(g.stops.len().saturating_sub(1));
// Preset variations of the first stop's colour.
@@ -168,8 +168,8 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
GradientShape::Path { .. } => "Radial",
};
ui.horizontal(|ui| {
ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Type"));
egui::ComboBox::from_id_salt((id, "kind")).selected_text(kind).width(120.0).show_ui(ui, |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| {
for k in ["Linear", "Radial", "Rectangular", "Path"] {
if ui.selectable_label(kind == k, k).clicked() && kind != k {
let focus = match &g.shape {
@@ -186,6 +186,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
}
}
});
let _ = combo.response.labelled_by(field.id);
});
ui.horizontal_wrapped(|ui| {
ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Direction"));
@@ -211,7 +212,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
if let GradientShape::Linear { angle, .. } = &g.shape {
ui.horizontal(|ui| {
ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Angle"));
if let Some(a) = widgets::spinner(ui, &format!("{id}-angle"), *angle, 15.0, "°", 70.0) {
if let Some(a) = widgets::spinner(ui, &format!("{id}-angle"), "Angle", *angle, 15.0, "°", 70.0) {
out = Some(Gradient { shape: GradientShape::Linear { angle: a.rem_euclid(360.0), scaled: false }, ..g.clone() });
}
});
@@ -221,15 +222,17 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
ui.add_space(4.0);
ui.label("Gradient stops");
let (bar, bar_resp) = ui.allocate_exact_size(vec2(ui.available_width().min(260.0), 18.0), Sense::click());
bar_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Gradient bar"));
paint_bar(ui, bar, g, sc);
let (marks, _) = ui.allocate_exact_size(vec2(bar.width(), 14.0), Sense::hover());
let drag_id = ui.id().with((id, "drag"));
let drag_id = ui.scope_id().with((id, "drag"));
let mut dragging: Option<(usize, f64)> = ui.data_mut(|d| d.get_temp(drag_id));
for (i, s) in g.stops.iter().enumerate() {
let pos = dragging.filter(|d| d.0 == i).map_or(s.pos, |d| d.1);
let x = bar.min.x + pos as f32 * bar.width();
let r = Rect::from_center_size(pos2(x, marks.center().y), vec2(10.0, 12.0));
let resp = ui.interact(r, ui.id().with((id, "mark", i)), Sense::click_and_drag());
let resp = ui.interact(r, ui.scope_id().with((id, "mark", i)), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Gradient stop {}", i + 1)));
let fill = c32(&s.color, sc);
let stroke = if i == sel { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.handle_stroke) };
let tri = vec![pos2(x, r.min.y), pos2(r.max.x, r.min.y + 4.0), pos2(r.max.x, r.max.y), pos2(r.min.x, r.max.y), pos2(r.min.x, r.min.y + 4.0)];
@@ -296,7 +299,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
});
ui.horizontal(|ui| {
ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Position"));
if let Some(v) = widgets::spinner(ui, &format!("{id}-pos-{sel}"), (s.pos * 100.0).round(), 5.0, "%", 70.0) {
if let Some(v) = widgets::spinner(ui, &format!("{id}-pos-{sel}"), "Position", (s.pos * 100.0).round(), 5.0, "%", 70.0) {
let mut ng = g.clone();
ng.stops[sel].pos = (v / 100.0).clamp(0.0, 1.0);
ng.stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
@@ -304,10 +307,10 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
}
});
ui.horizontal(|ui| {
ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Transparency"));
let key = ui.id().with((id, "tr", sel));
let label = ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Transparency"));
let key = ui.scope_id().with((id, "tr", sel));
let mut tr: f32 = ui.data_mut(|d| d.get_temp(key).unwrap_or(((1.0 - alpha_of(&s.color)) * 100.0) as f32));
let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix("%"));
let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix("%")).labelled_by(label.id);
if r.changed() {
ui.data_mut(|d| d.insert_temp(key, tr));
}
@@ -481,7 +484,7 @@ pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option
let stroke = if on || resp.hovered() || resp.has_focus() { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) };
ui.painter().rect_stroke(r.shrink(1.0), CornerRadius::same(2), stroke, egui::StrokeKind::Inside);
let label = pattern_label(name);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, label));
if resp.on_hover_text(label).clicked() && !on {
out = Some(PatternFill { preset: name.to_string(), ..pt.clone() });
}
@@ -495,7 +498,7 @@ pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option
ui.painter().rect(sw, CornerRadius::same(3), c32(cur, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let r = widgets::drop_button(ui, "", vec2(24.0, 20.0), true);
let name = format!("{label} color");
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
widgets::color_popup(&r).show(|ui| {
if let Some(Some(c)) = widgets::color_grid(ui, sc, None) {
out = Some(if foreground { PatternFill { fg: c, ..pt.clone() } } else { PatternFill { bg: c, ..pt.clone() } });
+2
View File
@@ -57,6 +57,7 @@ fn the_thumbnail_pane_of_a_huge_deck_renders_only_the_slides_in_view() {
delta: egui::vec2(0.0, -40_000.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(60);
let scrolled = h.state().textures.thumb_count();
@@ -107,6 +108,7 @@ fn a_settled_thumbnail_pane_renders_nothing_more() {
delta: egui::vec2(0.0, -40_000.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(60);
let after = h.state().textures.thumb_renders();
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -44,8 +44,8 @@ fn select(h: &mut Harness<'static, SlideApp>, id: ShapeId) {
/// Open the drop-down arrow of a split button, type a hex colour into "More Colors…" and press Enter.
fn pick_hex(h: &mut Harness<'static, SlideApp>, button: &str, hex: &str) {
let r = h.get_by_label(button).rect();
let arrow = egui::pos2(r.max.x - 5.0, r.center().y);
// The split button exposes its main and arrow targets separately, named for this command.
let arrow = h.get_by_role_and_label(egui::accesskit::Role::Button, &format!("{button} options")).rect().center();
h.input_mut().events.push(Event::PointerMoved(arrow));
h.run_steps(2);
for pressed in [true, false] {
+2284 -166
View File
File diff suppressed because it is too large Load Diff
+34 -25
View File
@@ -288,10 +288,10 @@ impl MediaHost {
self.videos.get(&id).and_then(|v| v.tex.as_ref())
}
/// Per frame: run the clocks, upload due video frames, apply loop/rewind, and report state to
/// the engine. Returns whether anything is playing (the caller keeps repainting).
pub fn tick(&mut self, ctx: &egui::Context, session: &mut Session) -> bool {
let now = ctx.input(|i| i.time);
/// Run the media clocks, apply loop/rewind and report state to the engine. Called from
/// `logic` on every call (including hidden logic), with monotonic seconds, so playback does
/// not depend on a painted frame. Returns whether anything is playing.
pub fn tick_clock(&mut self, now: f64, session: &mut Session) -> bool {
self.player.tick(now);
let mut ended = vec![];
for (id, owned) in &self.owners {
@@ -306,21 +306,6 @@ impl MediaHost {
for id in ended {
self.stop(id);
}
for (id, v) in &mut self.videos {
let Some(st) = self.player.status(key(*id)) else { continue };
let Some(feed) = v.feed.as_mut() else { continue };
let f = feed.frame_at(st.position);
if let Some(f) = f
&& v.frame.as_ref().is_none_or(|old| !Arc::ptr_eq(old, &f))
{
let img = egui::ColorImage::from_rgba_unmultiplied([f.width as usize, f.height as usize], &f.rgba);
match v.tex.as_mut() {
Some(t) => crate::textures::replace(ctx, t, img),
None => v.tex = Some(crate::textures::upload(ctx, format!("media-{id}"), img)),
}
v.frame = Some(f);
}
}
session.media_status = self
.owners
.keys()
@@ -338,11 +323,27 @@ impl MediaHost {
})
})
.collect();
let playing = self.owners.keys().any(|id| self.is_playing(*id));
if playing {
ctx.request_repaint_after(std::time::Duration::from_millis(15));
self.owners.keys().any(|id| self.is_playing(*id))
}
/// Upload due video frames for the pass that is about to draw them. Called only from the real
/// UI phase: a hidden window accumulates no texture uploads and catches up when it appears.
pub fn sync_video_textures(&mut self, ctx: &egui::Context) {
for (id, v) in &mut self.videos {
let Some(st) = self.player.status(key(*id)) else { continue };
let Some(feed) = v.feed.as_mut() else { continue };
let f = feed.frame_at(st.position);
if let Some(f) = f
&& v.frame.as_ref().is_none_or(|old| !Arc::ptr_eq(old, &f))
{
let img = egui::ColorImage::from_rgba_unmultiplied([f.width as usize, f.height as usize], &f.rgba);
match v.tex.as_mut() {
Some(t) => crate::textures::replace(ctx, t, img),
None => v.tex = Some(crate::textures::upload(ctx, format!("media-{id}"), img)),
}
v.frame = Some(f);
}
}
playing
}
/// Carry out a `media.*` request from the engine for presentation `doc_uid`.
@@ -480,7 +481,14 @@ pub fn control_bar(ui: &mut Ui, rect: Rect, status: Option<&VoiceStatus>, clip:
let button = |ui: &mut Ui, x: &mut f32, id: &str, paint: &dyn Fn(&egui::Painter, Rect)| -> bool {
let r = Rect::from_min_size(pos2(*x, rect.min.y + 2.0), vec2(h - 4.0, h - 4.0));
*x += h - 2.0;
let resp = ui.interact(r, ui.id().with(("media_bar", id)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("media_bar", id)), Sense::click());
let name = match id {
"play" => "Play or pause media",
"back" => "Back 0.25 seconds",
"fwd" => "Forward 0.25 seconds",
_ => "Media control",
};
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name));
if resp.hovered() {
painter.rect_filled(r, CornerRadius::same(3), Color32::from_white_alpha(28));
}
@@ -534,7 +542,8 @@ pub fn control_bar(ui: &mut Ui, rect: Rect, status: Option<&VoiceStatus>, clip:
}
painter.circle_filled(pos2(done.max.x, track.center().y), 5.0, white);
let hit = track.expand2(vec2(4.0, 8.0));
let resp = ui.interact(hit, ui.id().with("media_bar_track"), Sense::click_and_drag());
let resp = ui.interact(hit, ui.scope_id().with("media_bar_track"), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Media position"));
if (resp.clicked() || resp.dragged())
&& duration > 0.0
&& let Some(p) = resp.interact_pointer_pos()
+18 -3
View File
@@ -10,6 +10,9 @@ use std::sync::mpsc::{Receiver, TryRecvError};
pub struct Opening {
pub path: String,
pub started_ms: f64,
/// Monotonic identity of this attempt. Cancelling and reopening the same path must not match
/// a click queued for the previous progress window's Cancel button.
pub(crate) generation: u64,
rx: Receiver<Result<LoadedFile, String>>,
}
@@ -18,6 +21,13 @@ impl Opening {
pub fn name(&self) -> String {
std::path::Path::new(&self.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.path.clone())
}
/// A progress window for identity tests; the receiver never yields a file.
#[cfg(test)]
pub(crate) fn for_test(path: &str) -> Self {
let (_tx, rx) = std::sync::mpsc::channel();
Opening { path: path.to_string(), started_ms: 0.0, generation: 0, rx }
}
}
impl SlideApp {
@@ -44,7 +54,10 @@ impl SlideApp {
let _ = tx.send(r);
});
match spawned {
Ok(_) => self.opening = Some(Opening { path: path.to_string(), started_ms: crate::now_ms(), rx }),
Ok(_) => {
self.opening_generation = self.opening_generation.wrapping_add(1).max(1);
self.opening = Some(Opening { path: path.to_string(), started_ms: crate::now_ms(), generation: self.opening_generation, rx });
}
Err(_) => {
if let Err(e) = self.open_path(path) {
self.notify(e);
@@ -90,7 +103,7 @@ impl SlideApp {
let name = o.name();
let secs = ((crate::now_ms() - o.started_ms) / 1000.0).max(0.0);
let mut cancel = false;
egui::Modal::new(egui::Id::new("deckcraft-opening")).show(ctx, |ui| {
egui::Modal::new(egui::Id::unique("deckcraft-opening")).show(ctx, |ui| {
ui.set_width(320.0);
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(18.0));
@@ -178,13 +191,15 @@ mod tests {
assert!(app.toast.as_ref().is_some_and(|t| t.text.contains("missing.pptx")), "the reason is shown");
}
/// Opening a path displays progress; cancelling closes the progress window without opening the
/// deck.
#[test]
fn the_progress_window_shows_and_cancel_closes_it() {
use egui_kittest::kittest::Queryable;
let mut app = SlideApp::new(Session::with_new(), crate::Services::default());
// A read that never answers: the window stays until Cancel.
let (_tx, rx) = std::sync::mpsc::channel();
app.opening = Some(Opening { path: "/decks/Big deck.pptx".into(), started_ms: crate::now_ms(), rx });
app.opening = Some(Opening { path: "/decks/Big deck.pptx".into(), started_ms: crate::now_ms(), generation: 1, rx });
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
+79 -47
View File
@@ -82,11 +82,11 @@ fn smartart(app: &mut SlideApp, ui: &mut Ui) {
ui.add_space(4.0);
for (i, node) in d.nodes.iter().enumerate() {
let Some(draft) = drafts.get_mut(i) else { break };
let id = egui::Id::new(("smartart_line", gid.0, i));
let id = egui::Id::unique(("smartart_line", gid.0, i));
ui.horizontal(|ui| {
ui.add_space(16.0 * node.level as f32);
ui.label("•");
let te = egui::TextEdit::singleline(draft).id(id).desired_width(f32::INFINITY);
let te = egui::TextEdit::singleline(draft).id(id).hint_text("SmartArt text").desired_width(f32::INFINITY);
let r = ui.add(te);
if app.ui.smartart_focus == Some(i) {
r.request_focus();
@@ -204,6 +204,7 @@ fn color_button(
});
}
/// Render the shape-format pane with fill/line, effects, size/properties, text, and picture tabs.
fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
@@ -327,13 +328,13 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
}
}
"size" => {
let lock_id = egui::Id::new("fmt-lock-aspect");
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));
let locked = lock;
let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, unit: f64| {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
if let Some(nv) = widgets::spinner(ui, id, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) {
if let Some(nv) = widgets::spinner(ui, id, label, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) {
let mut p = json!({key: nv * unit});
if cmd == "shape.resize" && locked {
p["lockAspect"] = json!(true);
@@ -355,10 +356,10 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
fields(ui, "Vertical", "fy", x.y, "y", "shape.move", 72.0);
ui.add_space(6.0);
ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0)));
let id = ui.id().with(("alt", uid, sh.id.0));
let id = ui.scope_id().with(("alt", uid, sh.id.0));
let stored: Option<(String, String)> = ui.data_mut(|d| d.get_temp(id));
let mut alt = widgets::text_draft(stored, &sh.descr);
let r = ui.add(egui::TextEdit::multiline(&mut alt).desired_rows(3).desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::multiline(&mut alt).hint_text("Alt text").desired_rows(3).desired_width(f32::INFINITY));
if r.lost_focus() && alt != sh.descr {
run(app, "shape.altText", json!({"text": alt}));
}
@@ -403,7 +404,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
] {
ui.horizontal(|ui| {
ui.add_sized(vec2(60.0, 20.0), egui::Label::new(l));
if let Some(nv) = widgets::spinner(ui, &format!("m{k}"), v, 1.0, " pt", 60.0) {
if let Some(nv) = widgets::spinner(ui, &format!("m{k}"), l, v, 1.0, " pt", 60.0) {
run(app, "format.margins", json!({k: nv.max(0.0)}));
}
});
@@ -494,16 +495,16 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
if !transparency {
return;
}
ui.label("Transparency");
let label = ui.label("Transparency");
// Seed from this shape. A shared temp defaulted to 0, so a shape that was
// already 40% transparent showed as opaque, and leaving the slider wrote 0.
let seed = match &sh.fill {
Some(Fill::Picture(pf)) => ((1.0 - pf.alpha.unwrap_or(1.0)) * 100.0) as f32,
fill => solid_fill_transparency_percent(fill.as_ref()),
};
let id = ui.id().with(("ftr", sh.id.0));
let id = ui.scope_id().with(("ftr", sh.id.0));
let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(seed));
let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%"));
let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")).labelled_by(label.id);
if r.drag_stopped() || r.lost_focus() {
if let Some(t) = fill_transparency_to_apply(v, seed) {
let _ = app.run("shape.fill", json!({"transparency": t}));
@@ -551,7 +552,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.label("Width");
let w = sh.line.as_ref().and_then(|l| l.width).unwrap_or(0.75);
if let Some(v) = widgets::spinner(ui, "lw", w, 0.25, " pt", 60.0) {
if let Some(v) = widgets::spinner(ui, "lw", "Width", w, 0.25, " pt", 60.0) {
run(app, "shape.line", json!({"width": v.max(0.0)}));
}
});
@@ -572,15 +573,16 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
/// `shape.line` with `{key: value}`.
fn line_choice(app: &mut SlideApp, ui: &mut Ui, label: &str, key: &str, current: &str, items: &[(&str, &str)]) {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
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);
egui::ComboBox::from_id_salt(("fmt-line", key)).selected_text(shown).width(120.0).show_ui(ui, |ui| {
let combo = egui::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}));
}
}
});
let _ = combo.response.labelled_by(field.id);
});
}
@@ -644,11 +646,11 @@ fn fx_slider(
scale: f64,
) {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
let label = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
// Hold the value while dragging; otherwise follow the shape.
let sid = ui.id().with(id);
let sid = ui.scope_id().with(id);
let mut v: f64 = ui.data_mut(|d| d.get_temp(sid)).unwrap_or(value);
let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(0));
let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(0)).labelled_by(label.id);
if r.dragged() || r.has_focus() {
ui.data_mut(|d| d.insert_temp(sid, v));
} else {
@@ -675,9 +677,9 @@ fn pick_fill_picture(app: &mut SlideApp) {
/// when it changes.
fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str, scale: f64) {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
let mut v = value;
let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1));
let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)).labelled_by(field.id);
if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) {
run(app, "shape.fill", json!({"pictureProps": {key: v * scale}}));
}
@@ -717,24 +719,25 @@ fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft
let mirrors = [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")];
for (label, key, opts, cur) in [("Alignment", "align", &aligns[..], align), ("Mirror type", "mirror", &mirrors[..], flip)] {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
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);
egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| {
let combo = egui::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}}));
}
}
});
let _ = combo.response.labelled_by(field.id);
});
}
}
PictureMode::Stretch { fill_rect } => {
for (i, label) in ["Offset left", "Offset top", "Offset right", "Offset bottom"].into_iter().enumerate() {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
let mut v = fill_rect[i] * 100.0;
let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0));
let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0)).labelled_by(field.id);
if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) {
let mut rect = *fill_rect;
rect[i] = v / 100.0;
@@ -769,23 +772,24 @@ fn preset_label(name: &str) -> String {
/// A drop-down of `options`; picking one runs `shape.effects` with `{threeD: {key: option}}`.
fn three_d_combo(app: &mut SlideApp, ui: &mut Ui, id: u32, label: &str, key: &str, cur: &str, options: &[&str]) {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
egui::ComboBox::from_id_salt(("3d", id, key)).selected_text(preset_label(cur)).width(170.0).show_ui(ui, |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| {
for o in options {
if ui.selectable_label(*o == cur, preset_label(o)).clicked() {
run(app, "shape.effects", json!({"threeD": {key: o}}));
}
}
});
let _ = combo.response.labelled_by(field.id);
});
}
/// A number field running `shape.effects` with `{threeD: {key: value}}` when it changes.
fn three_d_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str) {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
let mut v = value;
let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1));
let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)).labelled_by(field.id);
if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) {
run(app, "shape.effects", json!({"threeD": {key: v}}));
}
@@ -933,6 +937,8 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
});
}
/// Render picture-fill background controls and route transparency, tiling, and tile geometry
/// changes through the engine.
fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_model::style::PictureFill) {
use deckcraft_model::style::PictureMode;
let alpha = pf.alpha.unwrap_or(1.0);
@@ -953,7 +959,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo
] {
ui.horizontal(|ui| {
ui.add_sized(vec2(72.0, 20.0), egui::Label::new(label));
if let Some(v) = widgets::spinner(ui, id, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) {
if let Some(v) = widgets::spinner(ui, id, label, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) {
let key = match id {
"bg-ox" => "offsetX",
"bg-oy" => "offsetY",
@@ -976,8 +982,8 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo
_ => "Top Left",
};
ui.horizontal(|ui| {
ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment"));
egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |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| {
for (k, l) in [
("tl", "Top Left"),
("t", "Top"),
@@ -994,6 +1000,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo
}
}
});
let _ = combo.response.labelled_by(field.id);
});
let mirror_name = match flip.as_str() {
"x" => "Horizontal",
@@ -1002,17 +1009,19 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo
_ => "None",
};
ui.horizontal(|ui| {
ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror"));
egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |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| {
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}}));
}
}
});
let _ = combo.response.labelled_by(field.id);
});
}
/// Render the animation pane and route its controls through the engine's animation commands.
fn animation(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
@@ -1026,7 +1035,7 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
// Play From starts at the effect last clicked here, or else the first effect of the selected
// shape; with nothing selected it is Play All. Reordering or removing forgets the click: the
// index would point at another effect.
let pick_id = egui::Id::new(("anim_pane_pick", st.uid, st.selection.slide));
let pick_id = egui::Id::unique(("anim_pane_pick", st.uid, st.selection.slide));
let pick = ui.data(|m| m.get_temp::<usize>(pick_id));
// A triggered effect is not on the click sequence, so it can't be played from.
let eligible = |i: usize| anims.get(i).is_some_and(|a| selected.contains(&a.shape.0)) && tl.start_of(i).is_some();
@@ -1100,17 +1109,17 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) {
pos2(x_of(b.start + b.span).max(x_of(b.start) + 4.0), r.center().y + 5.0),
);
let edge = Rect::from_min_max(pos2(bar.max.x - 3.0, bar.min.y - 3.0), pos2(bar.max.x + 4.0, bar.max.y + 3.0));
let body_resp = ui.interact(bar, ui.id().with(("anim_bar", i)), Sense::drag()).on_hover_text(animtimeline::label(b));
body_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("Timing bar {}", i + 1)));
let edge_resp = ui.interact(edge, ui.id().with(("anim_edge", i)), Sense::drag());
edge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("Duration handle {}", i + 1)));
let body_resp = ui.interact(bar, ui.scope_id().with(("anim_bar", i)), Sense::drag()).on_hover_text(animtimeline::label(b));
body_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, format!("Timing bar {}", i + 1)));
let edge_resp = ui.interact(edge, ui.scope_id().with(("anim_edge", i)), Sense::drag());
edge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, format!("Duration handle {}", i + 1)));
if edge_resp.hovered() || edge_resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
} else if body_resp.hovered() || body_resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
// The drag offset is remembered: the pointer's press origin is gone on release.
let dx_id = ui.id().with(("anim_drag_dx", i));
let dx_id = ui.scope_id().with(("anim_drag_dx", i));
let live = ui.input(|inp| match (inp.pointer.press_origin(), inp.pointer.interact_pos()) {
(Some(o), Some(p)) => Some(p.x - o.x),
_ => None,
@@ -1290,6 +1299,7 @@ fn focus_comment(app: &mut SlideApp, slide: usize, index: usize) {
app.ui.comment_focus = Some((slide, index));
}
/// Render one comment card, preserving its slide/index identity and focus state through actions.
fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: &deckcraft_model::Comment, focused: bool) {
let t = Tokens::get(ui.ctx());
let stroke = if focused { Stroke::new(2.0, t.accent) } else { Stroke::NONE };
@@ -1325,7 +1335,7 @@ fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c:
ui.label(egui::RichText::new(format!("↳ {}: {}", r.author, r.text)).color(t.text_dim));
}
ui.horizontal(|ui| {
let id = ui.id().with(("reply", slide, index));
let id = ui.scope_id().with(("reply", slide, index));
let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default());
let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0));
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() {
@@ -1350,7 +1360,7 @@ fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c:
});
});
if focused {
let key = egui::Id::new("comment-focus-scrolled");
let key = egui::Id::unique("comment-focus-scrolled");
let seen = ui.data(|d| d.get_temp::<(usize, usize)>(key));
if seen != Some((slide, index)) {
ui.scroll_to_rect(shown.response.rect, Some(egui::Align::Center));
@@ -1368,6 +1378,8 @@ struct IdeaThumbs {
tex: Vec<(String, egui::TextureHandle, u32, u32)>,
}
/// Render per-slide Design Ideas and whole-presentation theme thumbnails; a click runs
/// `design.applyIdea` or `design.theme`.
fn designer(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new("Pick a design for this slide. Ideas are generated on your device.").color(t.text_dim));
@@ -1382,7 +1394,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
let px = (w * ppp).round().max(16.0) as u32;
let ideas = deckcraft_engine::cmd::ideas::ideas_for(&doc, idx);
let themes = deckcraft_model::theme::builtin_themes();
let cache_id = egui::Id::new("design-idea-thumbs");
let cache_id = egui::Id::unique("design-idea-thumbs");
let key = (key_doc.0, key_doc.1, idx, px);
let mut cache: IdeaThumbs = ui.ctx().data_mut(|d| d.get_temp(cache_id)).unwrap_or_default();
if cache.key != key || cache.tex.is_empty() {
@@ -1463,11 +1475,11 @@ pub(crate) fn text_transparency_of(sh: &deckcraft_model::Shape) -> f64 {
/// Text Fill › Transparency, as in PowerPoint's Format Shape › Text Options (#32).
pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) {
ui.horizontal(|ui| {
ui.label("Transparency");
let id = ui.id().with(("text-transparency", sh.id));
let label = ui.label("Transparency");
let id = ui.scope_id().with(("text-transparency", sh.id));
let stored = (text_transparency_of(sh) * 100.0) as f32;
let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(stored));
let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%"));
let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")).labelled_by(label.id);
if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) {
let _ = app.run("format.textTransparency", json!({"value": v as f64 / 100.0}));
ui.data_mut(|d| d.remove::<f32>(id));
@@ -1572,9 +1584,14 @@ mod tests {
assert!(clear.iter().any(|v| (*v - 40.0).abs() < 0.5), "40% fill should show 40%, got {clear:?}");
assert_eq!(solid_alpha(h.state(), b), Some(60_000));
h.get_by_label("Transparency").click();
// 0.37 exposes the slider's numeric entry as a SpinButton with the same name; target the
// Slider itself so the click lands on the slider, not its text field. A click at its
// middle is a real edit (50%), not a side effect of opening the pane or selecting b.
h.get_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").click();
h.run_steps(2);
assert_eq!(solid_alpha(h.state(), b), Some(60_000));
assert_eq!(solid_alpha(h.state(), b), Some(50_000));
let changed: Vec<f64> = slider_values(&h);
assert!(changed.iter().any(|v| (*v - 50.0).abs() < 0.5), "the changed slider shows 50%, got {changed:?}");
}
/// A blank slide with one selected rectangle, its fill set by `fill` (none for an automatic fill),
@@ -1611,6 +1628,7 @@ mod tests {
h.query_all_by_role(egui::accesskit::Role::Button).filter(|n| n.accesskit_node().label().is_some_and(|l| l.trim() == "Color")).count()
}
/// Fill colour and transparency controls appear only for fill kinds they can actually modify.
#[test]
fn fill_color_and_transparency_show_only_where_they_change_the_fill() {
let gradient = json!({"gradient": {"stops": [[0, "accent1"], [1, "accent2"]], "angle": 90}});
@@ -1625,10 +1643,17 @@ mod tests {
let h = fill_pane(fill);
let fill_color = color_buttons(&h) == 2;
assert_eq!(fill_color, color, "{name}: fill Color button");
assert_eq!(h.query_all_by_label("Transparency").count(), transparency, "{name}: Transparency rows");
// 0.37 gives each slider's numeric entry its own SpinButton with the same name, so count
// the sliders themselves: one per visible Transparency row.
assert_eq!(
h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").count(),
transparency,
"{name}: Transparency rows"
);
}
}
/// The picture-fill transparency slider reflects and edits the picture fill's own transparency.
#[test]
fn picture_fill_transparency_slider_shows_the_picture_transparency() {
let mut h = fill_pane(Some(picture_fill(0.4)));
@@ -1641,7 +1666,14 @@ mod tests {
};
let shown = slider_values(&h);
assert!(shown.iter().any(|v| (*v - 40.0).abs() < 0.5), "a 40% transparent picture should show 40%, got {shown:?}");
h.get_by_label("Transparency").click();
// 0.37 exposes the slider's numeric entry as a SpinButton with the same name and excludes
// a label that names another control from label queries, so find the painted label itself:
// clicking it must leave the picture's transparency alone.
let label = h
.get_all_by_role(egui::accesskit::Role::Label)
.find(|n| n.value().as_deref() == Some("Transparency"))
.expect("the Transparency label is there");
label.click();
h.run_steps(2);
assert!(alpha(&h).is_some_and(|a| (a - 0.6).abs() < 1e-9), "{:?}", alpha(&h));
}
+14 -4
View File
@@ -60,27 +60,37 @@ fn paragraph_button_aligns_text_in_one_undo() {
assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Paragraph…");
}
/// Paragraph-dialog tab settings apply the chosen stop and return to the paragraph controls.
#[test]
fn tabs_from_paragraph_applies_a_stop_and_returns() {
let mut h = harness();
h.get_by_label("Paragraph…").click();
h.run_steps(2);
h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into());
h.get_by_label("Tabs…").click_accesskit();
h.run_steps(2);
// Settle the anchored window's geometry before hit-testing the button with the real pointer
// path. egui 0.37 keeps a window invisible for its first sizing frame; AccessKit can activate
// such a node, but a pointer must actually hit the visible button inside the window.
h.run_steps(3);
let tabs = h.get_by_label("Tabs…");
let rect = tabs.rect();
assert!(rect.min.x >= 0.0 && rect.max.x <= 1440.0 && rect.min.y >= 0.0 && rect.max.y <= 900.0, "Tabs… has settled on screen: {rect:?}");
tabs.click();
// The click switches the dialog at the end of one pass; wait for that and the first visible
// frame before querying the Tabs window's buttons.
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("tabs"), "status {:?}", h.state().status);
{
let d = h.state_mut().dialog.as_mut().unwrap();
d.fields.insert("stops".into(), "2:ctr".into());
}
h.get_by_label("OK").click_accesskit();
h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click_accesskit();
// The Tabs OK frame restores Paragraph; the next frame fills its fields.
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph"), "status {:?}", h.state().status);
assert_eq!(h.state().dialog.as_ref().unwrap().fields.get("align").map(String::as_str), Some("right"));
assert_eq!(tabs_of(&h), vec![(144.0, "ctr".into())]);
assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Tabs…");
h.get_by_label("Cancel").click_accesskit();
h.get_by_role_and_label(egui::accesskit::Role::Button, "Cancel").click_accesskit();
h.run_steps(1);
assert!(h.state().dialog.is_none());
assert_eq!(align_of(&h).as_deref(), Some("ctr"), "cancelling Paragraph leaves the alignment shape.insert wrote");
+4 -1
View File
@@ -136,12 +136,15 @@ fn pattern_gallery_shows_which_swatch_has_keyboard_focus() {
assert!(outlined(&h), "the focused swatch is outlined");
}
/// A solid fill shows its transparency slider and does not expose a pattern gallery.
#[test]
fn shape_solid_fill_keeps_its_transparency_without_a_pattern_gallery() {
let (mut s, _) = patterned_shape();
s.execute("shape.fill", &json!({"color": "accent2"})).unwrap();
let h = harness(SlideApp::new(s, Services::default()), "format");
let _ = h.get_by_label("Transparency");
// 0.37 exposes the slider's numeric entry as a SpinButton with the same name; the slider is
// the control the user drags.
let _ = h.get_by_role_and_label(Role::Slider, "Transparency");
assert!(h.query_by_label("Foreground color").is_none());
assert!(h.query_by_label("Zig zag").is_none());
}
+5 -1
View File
@@ -76,6 +76,8 @@ fn create_inserts_the_album_in_one_step() {
assert_eq!(text, ["Text Box"]);
}
/// Updating photo-album data reflows the already-open album without requiring it to be
/// reopened.
#[test]
fn update_reflows_the_open_album() {
let data = deckcraft_engine::cmd::base64_encode(&png());
@@ -89,7 +91,9 @@ fn update_reflows_the_open_album() {
assert!(h.query_by_label("Update").is_some());
h.get_by_label("ALL pictures black and white").click();
h.run_steps(2);
h.get_by_label("1 picture").click();
let layout = h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Picture layout");
assert_eq!(layout.value().as_deref(), Some("1 picture"));
layout.click();
h.run_steps(2);
h.get_by_label("2 pictures").click();
h.run_steps(2);
+5 -2
View File
@@ -2,7 +2,7 @@
//! window's close button put the settings back the way they were (#752, reported as #878).
use deckcraft_engine::Session;
use egui_kittest::kittest::{NodeT, Queryable};
use egui_kittest::kittest::Queryable;
use egui_kittest::{Harness, Node};
use serde_json::json;
@@ -11,7 +11,10 @@ use crate::{Services, SlideApp};
/// The slider itself, not the label drawn beside it.
fn interface_size<'a>(h: &'a Harness<'_, SlideApp>) -> Node<'a> {
h.get_all_by_label("Interface size").find(|n| n.accesskit_node().role() == egui::accesskit::Role::Slider).expect("the slider is there")
let mut sliders = h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Interface size");
let slider = sliders.next().expect("the slider is there");
assert!(sliders.next().is_none(), "exactly one Interface size slider");
slider
}
fn harness() -> Harness<'static, SlideApp> {
+25 -6
View File
@@ -7,6 +7,7 @@ use serde_json::json;
use crate::{Services, SlideApp};
/// Build the standard Replace Fonts UI harness with a fresh session.
fn harness() -> Harness<'static, SlideApp> {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
@@ -15,11 +16,10 @@ fn harness() -> Harness<'static, SlideApp> {
s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap();
s.execute("format.size", &json!({"size": 40})).unwrap();
let app = SlideApp::new(s, Services::default());
// Home's ribbon is wider than it was when this test was written: at 1400 the Editing group
// begins at x=1398, so the Replace Fonts button is two pixels wide on screen and a click at
// its centre lands outside the window. The ribbon does not scroll sideways, so the window has
// to be wide enough to hold it -- 1800 is what the other full-ribbon tests here use.
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).with_step_dt(1.0 / 60.0).build_ui_state(
// The product's default window. The Home ribbon overflows here with the bundled interface
// fonts; the ribbon's own edge chevron must make its Editing group reachable instead of the
// regression widening the fixture past the width the app actually opens at.
let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
@@ -42,10 +42,29 @@ fn run_font(h: &Harness<'_, SlideApp>) -> Option<String> {
.and_then(|r| r.props.font.clone())
}
/// Scroll the ribbon to its right edge through its own visible overflow control.
fn reveal_ribbon_end(h: &mut Harness<'_, SlideApp>) {
for _ in 0..16 {
let Some(cue) = h.query_by_label("Scroll ribbon right") else { break };
cue.click();
h.run_steps(1);
}
}
/// The ribbon command opens Replace Fonts; its Replace button updates the document and leaves
/// the dialog open with a confirmation.
#[test]
fn ribbon_button_opens_the_dialog_and_replaces() {
let mut h = harness();
h.get_by_label("Replace Fonts…").click();
reveal_ribbon_end(&mut h);
let button = h.get_by_label("Replace Fonts…");
assert!(button.rect().max.x <= 1440.0 && button.rect().min.x >= 0.0, "Replace Fonts… is on screen at the default width: {:?}", button.rect());
if let Some(left) = h.query_by_label("Scroll ribbon left") {
let cue = left.rect();
assert!(!button.rect().intersects(cue), "the left overflow cue {cue:?} does not cover Replace Fonts… {:?}", button.rect());
assert!(button.rect().min.x >= cue.max.x, "ribbon content starts after the reserved left chevron gutter");
}
button.click();
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("replaceFonts"));
// Pick the font to replace and type the new one, the way a person would.
+264 -154
View File
@@ -8,7 +8,9 @@ use serde_json::{Value, json};
use crate::SlideApp;
use crate::icons::{self, Icon};
use crate::theme::{self, Tokens};
use crate::widgets::{self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, group, icon_toggle, rows, small_button, small_button_ex};
use crate::widgets::{
self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, group, icon_toggle, rows, small_button, small_button_ex, small_button_named,
};
pub const TITLE_H: f32 = 38.0;
@@ -58,7 +60,9 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) {
egui::Panel::top("title_bar").exact_size(height).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
let rect = ui.max_rect();
// Dragging the empty title bar moves the window.
let drag = ui.interact(rect, ui.id().with("titledrag"), Sense::click_and_drag());
let drag = ui.interact(rect, ui.scope_id().with("titledrag"), Sense::click_and_drag());
// The drag surface is not a button: a full-width Button would be announced as one.
drag.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Window, true, "Window title bar"));
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
@@ -170,8 +174,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
{
let w = ui.fonts_mut(|f| f.layout_no_wrap("File".to_string(), theme::font(13.0), t.text).size().x) + 18.0;
let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0));
let resp = ui.interact(r, ui.id().with("tab_file"), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "File"));
let resp = ui.interact(r, ui.scope_id().with("tab_file"), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "File"));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover);
}
@@ -186,7 +190,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let font = theme::font(13.0);
let w = ui.fonts_mut(|f| f.layout_no_wrap(name.to_string(), font.clone(), t.text).size().x) + 18.0;
let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0));
let resp = ui.interact(r, ui.id().with(("tab", name)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("tab", name)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name));
let active = app.ui.tab == name;
if resp.hovered() && !active {
ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover);
@@ -219,43 +224,101 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
ui.painter().rect_filled(card.translate(vec2(0.0, 1.0)), CornerRadius::same(10), t.card_border);
ui.painter().rect_filled(card, CornerRadius::same(10), t.card);
let inner = Rect::from_min_size(pos2(card.min.x + 10.0, card.min.y + 5.0), vec2(card.width() - 20.0, RIBBON_H));
let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Min)));
let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(inner));
cui.set_clip_rect(inner.expand2(vec2(0.0, 3.0)));
cui.spacing_mut().item_spacing.x = 1.0;
let ui = &mut cui;
// Reserve a gutter on both sides for the overflow chevrons. They are painted and
// interacted in that gutter, never on top of the first/last visible button: the bar they
// reveal must not steal the click they were meant to make reachable.
let edge = 18.0_f32.min(inner.width() * 0.25);
let scroll_rect = inner.shrink2(vec2(edge, 0.0));
let mut sui = ui.new_child(egui::UiBuilder::new().max_rect(scroll_rect));
sui.set_clip_rect(scroll_rect.expand2(vec2(0.0, 3.0)));
let tab = app.ui.tab.clone();
match tab.as_str() {
"Home" => home(app, ui),
"Insert" => insert(app, ui),
"Draw" => draw(app, ui),
"Design" => design(app, ui),
"Transitions" => transitions(app, ui),
"Animations" => animations(app, ui),
"Slide Show" => slide_show(app, ui),
"Record" => record(app, ui),
"Review" => review(app, ui),
"View" => view(app, ui),
"Shape Format" => shape_format(app, ui),
"Picture Format" => picture_format(app, ui),
"Table Design" => table_design(app, ui),
"Layout" => table_layout(app, ui),
"Chart Design" => chart_design(app, ui),
"SmartArt Design" => smartart_design(app, ui),
"Playback" => playback(app, ui),
"Slide Master" => slide_master(app, ui),
"Handout Master" => handout_master(app, ui),
"Notes Master" => notes_master(app, ui),
_ => {}
// The card used to clip a too-wide group row with no way to reach the rest. A horizontal
// scroll area plus edge chevrons keeps every group reachable at the default 1440 px
// window, including Home's Editing group (Replace Fonts…). The bar is hidden; the
// chevrons are the visible overflow control and are accessible buttons of their own.
let request_id = cui.make_persistent_id(("ribbon_scroll_request", tab.as_str()));
let requested = cui.ctx().data_mut(|d| d.remove_temp::<f32>(request_id));
let mut area = egui::ScrollArea::horizontal().id_salt(("ribbon_scroll", tab.as_str()));
if let Some(offset) = requested {
area = area.horizontal_scroll_offset(offset);
}
let scroll =
area.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).auto_shrink([false, false]).show(&mut sui, |ui| {
ui.set_min_height(RIBBON_H);
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
match tab.as_str() {
"Home" => home(app, ui),
"Insert" => insert(app, ui),
"Draw" => draw(app, ui),
"Design" => design(app, ui),
"Transitions" => transitions(app, ui),
"Animations" => animations(app, ui),
"Slide Show" => slide_show(app, ui),
"Record" => record(app, ui),
"Review" => review(app, ui),
"View" => view(app, ui),
"Shape Format" => shape_format(app, ui),
"Picture Format" => picture_format(app, ui),
"Table Design" => table_design(app, ui),
"Layout" => table_layout(app, ui),
"Chart Design" => chart_design(app, ui),
"SmartArt Design" => smartart_design(app, ui),
"Playback" => playback(app, ui),
"Slide Master" => slide_master(app, ui),
"Handout Master" => handout_master(app, ui),
"Notes Master" => notes_master(app, ui),
_ => {}
}
});
});
let r = scroll.inner_rect;
let overflow = (scroll.content_size.x - r.width()).max(0.0);
let offset = scroll.state.clamped_offset().x;
// Record the rendered offset for `InputTarget`: the same window point hits a different
// button when the ribbon scrolls between queueing and delivering a control click.
app.ribbon_scroll.insert(tab.clone(), offset.to_bits());
let step = 160.0;
let mut next = None;
if offset > 1.0 {
let left = Rect::from_min_max(pos2((r.left() - edge).max(inner.left()), r.top()), pos2(r.left(), r.bottom()));
if edge_cue(&mut cui, left, "‹", "Scroll ribbon left", &t) {
next = Some(offset - step);
}
}
if overflow - offset > 1.0 {
let right = Rect::from_min_max(pos2(r.right(), r.top()), pos2((r.right() + edge).min(inner.right()), r.bottom()));
if edge_cue(&mut cui, right, "›", "Scroll ribbon right", &t) {
next = Some(offset + step);
}
}
if let Some(x) = next {
ui.ctx().data_mut(|d| d.insert_temp(request_id, x.clamp(0.0, overflow)));
ui.ctx().request_repaint();
}
});
}
/// A chevron over a ribbon edge that scrolls it; `true` on the frame it is clicked.
fn edge_cue(ui: &mut Ui, rect: Rect, glyph: &str, hint: &str, t: &Tokens) -> bool {
let resp = ui.interact(rect, ui.scope_id().with(glyph), Sense::click()).on_hover_text(hint);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, hint));
let ink = if resp.hovered() { t.text } else { t.text_dim };
ui.painter().rect_filled(rect, CornerRadius::ZERO, t.card);
ui.painter().text(rect.center(), Align2::CENTER_CENTER, glyph, egui::FontId::proportional(18.0), ink);
resp.clicked()
}
/// Draw a large ribbon command button, honour its enabled state, run the command on click, and
/// attach its menu.
fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) {
let en = enabled(app, id);
let r = big_button(ui, icon, label, en);
if id == "insert.dateTime" {
let name = label.replace('\n', " ");
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, &name));
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, &name));
}
let r = r.on_hover_text(tip(id, label));
if r.clicked() {
@@ -269,10 +332,10 @@ fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) {
let en = enabled(app, "insert.photoAlbum");
let (main, arrow) = big_button_ex(ui, Icon::Picture, "Photo\nAlbum", en, true, false);
let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…"));
main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, "Photo Album…"));
main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album…"));
if let Some(a) = &arrow {
let _ = a.clone().on_hover_text("Photo Album options");
a.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, "Photo Album options"));
a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album options"));
}
if main.clicked() {
run(app, "insert.photoAlbum", json!({}));
@@ -420,6 +483,7 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) {
});
}
/// Render the Home Slides group: New Slide, Layout, Reset, and Section controls.
fn slides_group(app: &mut SlideApp, ui: &mut Ui) {
group(ui, |ui| {
let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false);
@@ -427,42 +491,52 @@ fn slides_group(app: &mut SlideApp, ui: &mut Ui) {
run(app, "slide.new", json!({}));
}
if let Some(a) = arrow {
egui::Popup::menu(&a).width(420.0).show(|ui| {
layout_menu(app, ui, "slide.new");
ui.separator();
if ui.button("Duplicate Selected Slides").clicked() {
run(app, "slide.duplicate", json!({}));
}
if ui.button("Slides from Outline…").clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("outline"));
}
let desktop = app.services.pick_open.is_some();
for (label, keep) in [("Reuse Slides…", false), ("Reuse Slides (Keep Source Formatting)…", true)] {
let r = ui.add_enabled(desktop, egui::Button::new(label));
if r.clicked() {
run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep}));
}
r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app");
}
});
egui::Popup::menu(&a).width(420.0).show(|ui| new_slide_menu(app, ui));
}
rows(ui, |ui| {
let (r, _) = small_button_ex(ui, Icon::Layout, "Layout", enabled(app, "slide.layout"), false, true);
egui::Popup::menu(&r).width(420.0).show(|ui| layout_menu(app, ui, "slide.layout"));
let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layout", enabled(app, "slide.layout"), false, true);
if let Some(arrow) = arrow {
widgets::split_menu(&main, &arrow, |ui| layout_menu(app, ui, "slide.layout"));
}
small(app, ui, Icon::ResetSlide, "Reset", "slide.reset", json!({}));
let (r, _) = small_button_ex(ui, Icon::Section, "Section", enabled(app, "section.add"), false, true);
egui::Popup::menu(&r).show(|ui| {
if ui.button("Add Section").clicked() {
run(app, "section.add", json!({}));
}
if ui.button("Remove All Sections").clicked() {
run(app, "section.removeAll", json!({}));
}
});
let (main, arrow) = small_button_ex(ui, Icon::Section, "Section", enabled(app, "section.add"), false, true);
if let Some(arrow) = arrow {
widgets::split_menu(&main, &arrow, |ui| section_menu(app, ui));
}
});
});
}
/// The New Slide button's menu: layouts, duplication, outline import and slide reuse.
fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) {
layout_menu(app, ui, "slide.new");
ui.separator();
if ui.button("Duplicate Selected Slides").clicked() {
run(app, "slide.duplicate", json!({}));
}
if ui.button("Slides from Outline…").clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("outline"));
}
let desktop = app.services.pick_open.is_some();
for (label, keep) in [("Reuse Slides…", false), ("Reuse Slides (Keep Source Formatting)…", true)] {
let r = ui.add_enabled(desktop, egui::Button::new(label));
if r.clicked() {
run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep}));
}
r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app");
}
}
/// The Section split button's menu.
fn section_menu(app: &mut SlideApp, ui: &mut Ui) {
if ui.button("Add Section").clicked() {
run(app, "section.add", json!({}));
}
if ui.button("Remove All Sections").clicked() {
run(app, "section.removeAll", json!({}));
}
}
pub fn font_families(app: &SlideApp) -> Vec<String> {
let mut v = vec![];
if let Some(m) = app.session.active().and_then(|d| d.doc.masters.first()) {
@@ -677,7 +751,7 @@ fn smartart_gallery(
None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)),
};
ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false));
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, *label));
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label));
r.on_hover_text(*label)
})
.inner;
@@ -705,6 +779,7 @@ fn smartart_gallery(
});
}
/// Render paragraph formatting controls using the current paragraph state.
fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
let en = enabled(app, "format.bullets");
group(ui, |ui| {
@@ -712,7 +787,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
let (m, a) = small_button_ex(ui, Icon::Bullets, "", en, b("bullets"), true);
let (m, a) = small_button_named(ui, Icon::Bullets, "", "Bullets", en, b("bullets"), true);
if m.on_hover_text("Bullets").clicked() {
run(app, "format.bullets", json!({}));
}
@@ -734,7 +809,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
}
});
}
let (m, a) = small_button_ex(ui, Icon::Numbering, "", en, b("numbering"), true);
let (m, a) = small_button_named(ui, Icon::Numbering, "", "Numbering", en, b("numbering"), true);
if m.on_hover_text("Numbering").clicked() {
run(app, "format.numbering", json!({}));
}
@@ -929,6 +1004,7 @@ fn kurbo_flatten(path: &deckcraft_geom::BezPath, mut f: impl FnMut(Fl)) {
});
}
/// Render drawing controls for shapes, text, arrange, quick styles, fill, and outline.
fn drawing_group(app: &mut SlideApp, ui: &mut Ui) {
group(ui, |ui| {
let en = enabled(app, "shape.insert");
@@ -972,9 +1048,9 @@ fn drawing_group(app: &mut SlideApp, ui: &mut Ui) {
if let Some(a) = a {
widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc));
}
let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
if let Some(a) = a {
egui::Popup::menu(&a).show(|ui| effects_menu(app, ui));
let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
if let Some(arrow) = arrow {
widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui));
}
});
});
@@ -1344,7 +1420,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) {
/// Hover-to-size table grid. Returns (rows, cols) when clicked.
pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> {
let t = Tokens::get(ui.ctx());
let id = ui.id().with("tgrid");
let id = ui.scope_id().with("tgrid");
let mut hover: (usize, usize) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, 0)));
let cell = 16.0;
let label = if hover.0 > 0 { format!("{}x{} Table", hover.1, hover.0) } else { "Insert Table".into() };
@@ -1408,7 +1484,7 @@ fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_eng
fn icon_library(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let (color, hover) = (t.text, t.hover);
let id = ui.id().with("icon-query");
let id = ui.scope_id().with("icon-query");
let mut query = ui.data_mut(|d| d.get_temp::<String>(id).unwrap_or_default());
ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search icons").desired_width(ui.available_width()));
let q = query.trim().to_lowercase();
@@ -1518,6 +1594,8 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) {
// ---------- Design ----------
/// Render the Design tab's themes, variants, colours, fonts, effects, background, and slide
/// size controls.
fn design(app: &mut SlideApp, ui: &mut Ui) {
let themes = deckcraft_model::theme::builtin_themes();
let cur = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.name.clone())).unwrap_or_default();
@@ -1568,7 +1646,7 @@ fn design(app: &mut SlideApp, ui: &mut Ui) {
}
group(ui, |ui| {
let r = big_button(ui, Icon::Colors, "Colors", true);
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Colors"));
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Colors"));
egui::Popup::menu(&r).width(240.0).show(|ui| {
for cs in deckcraft_model::theme::builtin_color_schemes() {
ui.horizontal(|ui| {
@@ -1595,7 +1673,7 @@ fn design(app: &mut SlideApp, ui: &mut Ui) {
}
});
let r = big_button(ui, Icon::Fonts, "Fonts", true);
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fonts"));
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Fonts"));
egui::Popup::menu(&r).width(220.0).show(|ui| {
for fs in deckcraft_model::theme::builtin_font_schemes() {
if ui.button(format!("{}\n {} / {}", fs.name, fs.major.latin, fs.minor.latin)).clicked() {
@@ -1609,7 +1687,7 @@ fn design(app: &mut SlideApp, ui: &mut Ui) {
}
});
let r = big_button(ui, Icon::ShapeEffects, "Effects", true);
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Effects"));
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Effects"));
egui::Popup::menu(&r).width(180.0).show(|ui| {
for fs in deckcraft_model::theme::builtin_format_schemes() {
if ui.button(&fs.name).clicked() {
@@ -1685,6 +1763,8 @@ pub fn paint_theme_tile(p: &egui::Painter, r: Rect, th: &deckcraft_model::Theme)
// ---------- Transitions ----------
/// Render transition preview, gallery, options, sound, and timing controls for the current
/// slide.
fn transitions(app: &mut SlideApp, ui: &mut Ui) {
let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone()));
let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into());
@@ -1749,14 +1829,14 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) {
// Sound in its own column, so the timing column keeps its three rows.
rows(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Sound:");
let field = ui.label("Sound:");
let stop = cur.as_ref().is_some_and(|t| t.stop_sound);
let name = cur
.as_ref()
.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() });
egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| {
let combo = egui::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}));
@@ -1785,13 +1865,14 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) {
run(app, "transition.sound", json!({"loop": lp}));
}
});
let _ = combo.response.labelled_by(field.id);
});
});
rows(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Duration:");
let d = cur.as_ref().map(|t| t.duration_ms as f64 / 1000.0).unwrap_or(1.0);
if let Some(v) = widgets::spinner(ui, "tdur", d, 0.25, "", 46.0) {
if let Some(v) = widgets::spinner(ui, "tdur", "Duration", d, 0.25, "", 46.0) {
run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64}));
}
});
@@ -1807,7 +1888,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) {
if ui.checkbox(&mut on, "After:").changed() {
run(app, "transition.timing", json!({"after": if on { json!(after.unwrap_or(0)) } else { Value::Null }}));
}
if let Some(v) = widgets::spinner(ui, "tafter", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) {
if let Some(v) = widgets::spinner(ui, "tafter", "After", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) {
run(app, "transition.timing", json!({"after": (v.max(0.0) * 1000.0).round() as u64}));
}
});
@@ -1913,6 +1994,7 @@ pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &st
// ---------- Animations ----------
/// Render animation preview, gallery, add/effect options, trigger, timing, and playback controls.
fn animations(app: &mut SlideApp, ui: &mut Ui) {
let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty());
let cur: Option<deckcraft_model::Animation> = app.session.active().and_then(|d| {
@@ -2076,13 +2158,17 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) {
});
ui.horizontal(|ui| {
ui.label("Duration:");
if let Some(v) = widgets::spinner(ui, "adur", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0) {
if let Some(v) =
widgets::spinner(ui, "adur", "Duration", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0)
{
run(app, "animation.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64}));
}
});
ui.horizontal(|ui| {
ui.label("Delay:");
if let Some(v) = widgets::spinner(ui, "adelay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) {
if let Some(v) =
widgets::spinner(ui, "adelay", "Delay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0)
{
run(app, "animation.timing", json!({"delay": (v.max(0.0) * 1000.0).round() as u64}));
}
});
@@ -2433,6 +2519,7 @@ fn stroke_guide(p: &egui::Painter, g: &deckcraft_geom::warp::Guide, color: Color
}
}
/// Render the Shape Format tab's shape, fill, outline, effects, text, arrange, and size controls.
fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
let sc = scheme(app);
let sel = enabled(app, "shape.fill");
@@ -2503,17 +2590,17 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
if let Some(a) = a {
widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc));
}
let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
if let Some(a) = a {
egui::Popup::menu(&a).show(|ui| effects_menu(app, ui));
let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
if let Some(arrow) = arrow {
widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui));
}
});
});
group(ui, |ui| {
rows(ui, |ui| {
let (_, a) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true);
if let Some(a) = a {
widgets::color_popup(&a).show(|ui| {
let (main, arrow) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true);
if let Some(arrow) = arrow {
widgets::split_color_popup(&main, &arrow).show(|ui| {
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
run(app, "format.color", json!({"color": cref_param(&c)}));
}
@@ -2523,9 +2610,9 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
}
});
}
let (_, a) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true);
if let Some(a) = a {
widgets::color_popup(&a).show(|ui| {
let (main, arrow) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true);
if let Some(arrow) = arrow {
widgets::split_color_popup(&main, &arrow).show(|ui| {
if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) {
run(app, "format.textOutline", json!({"color": c.map(|c| cref_param(&c))}));
}
@@ -2552,6 +2639,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
});
}
/// Render ordering, align, group/ungroup, and rotate controls for the current selection.
fn arrange_group(app: &mut SlideApp, ui: &mut Ui) {
group(ui, |ui| {
let sel = enabled(app, "arrange.bringForward");
@@ -2579,17 +2667,23 @@ fn arrange_group(app: &mut SlideApp, ui: &mut Ui) {
let r = big_button(ui, Icon::AlignObjects, "Align", sel);
egui::Popup::menu(&r).show(|ui| align_menu(app, ui));
rows(ui, |ui| {
let (_, a) = small_button_ex(ui, Icon::Group, "Group", sel, false, true);
if let Some(a) = a {
egui::Popup::menu(&a).show(|ui| {
let (main, arrow) = small_button_ex(ui, Icon::Group, "Group", sel, false, true);
if main.clicked() {
run(app, "arrange.group", json!({}));
}
if let Some(arrow) = arrow {
egui::Popup::menu(&arrow).show(|ui| {
menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({}));
menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({}));
menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({}));
});
}
let (_, a) = small_button_ex(ui, Icon::Rotate, "Rotate", sel, false, true);
if let Some(a) = a {
egui::Popup::menu(&a).show(|ui| {
let (main, arrow) = small_button_ex(ui, Icon::Rotate, "Rotate", sel, false, true);
if main.clicked() {
run(app, "arrange.rotateRight", json!({}));
}
if let Some(arrow) = arrow {
egui::Popup::menu(&arrow).show(|ui| {
menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({}));
menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({}));
menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({}));
@@ -2600,6 +2694,7 @@ fn arrange_group(app: &mut SlideApp, ui: &mut Ui) {
});
}
/// Render width and height controls bound to the selected shape, plus the size-position dialog.
fn size_group(app: &mut SlideApp, ui: &mut Ui) {
let bx = app
.session
@@ -2614,13 +2709,13 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) {
};
ui.horizontal(|ui| {
ui.label("Height:");
if let Some(v) = widgets::spinner(ui, "sh", x.h / 72.0, 0.1, "\"", 54.0) {
if let Some(v) = widgets::spinner(ui, "sh", "Height", x.h / 72.0, 0.1, "\"", 54.0) {
run(app, "shape.resize", json!({"h": v.max(0.0) * 72.0}));
}
});
ui.horizontal(|ui| {
ui.label("Width: ");
if let Some(v) = widgets::spinner(ui, "sw", x.w / 72.0, 0.1, "\"", 54.0) {
if let Some(v) = widgets::spinner(ui, "sw", "Width", x.w / 72.0, 0.1, "\"", 54.0) {
run(app, "shape.resize", json!({"w": v.max(0.0) * 72.0}));
}
});
@@ -2631,6 +2726,8 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) {
});
}
/// Render the Picture Format tab's corrections, colour, artistic effects, transparency, styles,
/// border, and picture actions.
fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
group(ui, |ui| {
let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", enabled(app, "picture.removeBackground"));
@@ -2712,9 +2809,12 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
rows(ui, |ui| {
small(app, ui, Icon::ChangePicture, "Change Picture", "app.changePictureDialog", json!({}));
small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({}));
let (_, r) = small_button_ex(ui, Icon::CompressPictures, "Compress Pictures", enabled(app, "picture.compress"), false, true);
if let Some(r) = r {
egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(260.0).show(|ui| compress_menu(app, ui));
let (main, arrow) = small_button_ex(ui, Icon::CompressPictures, "Compress Pictures", enabled(app, "picture.compress"), false, true);
if let Some(arrow) = arrow {
widgets::split_popup(&main, &arrow)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.width(260.0)
.show(|ui| compress_menu(app, ui));
}
});
});
@@ -3133,21 +3233,23 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
run(app, "view.pane", json!({"pane": "smartart", "toggle": true}));
}
rows(ui, |ui| {
let (r, _) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true);
egui::Popup::menu(&r).show(|ui| {
for (label, pos) in [
("Add Shape After", "after"),
("Add Shape Before", "before"),
("Add Shape Above", "above"),
("Add Shape Below", "below"),
("Add Assistant", "assistant"),
] {
if ui.button(label).clicked() {
let index = if n == 0 { Value::Null } else { json!(at) };
run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos}));
let (main, arrow) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true);
if let Some(arrow) = arrow {
widgets::split_menu(&main, &arrow, |ui| {
for (label, pos) in [
("Add Shape After", "after"),
("Add Shape Before", "before"),
("Add Shape Above", "above"),
("Add Shape Below", "below"),
("Add Assistant", "assistant"),
] {
if ui.button(label).clicked() {
let index = if n == 0 { Value::Null } else { json!(at) };
run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos}));
}
}
}
});
});
}
if small_button(ui, Icon::DecreaseIndent, "Promote", n > 0, false).clicked() {
run(app, "smartArt.promote", json!({"id": gid, "index": at}));
}
@@ -3164,47 +3266,55 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
}
});
group(ui, |ui| {
let (r, _) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true);
let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true);
let outline = sel.as_ref().map(|(_, d)| d.nodes.clone());
egui::Popup::menu(&r).show(|ui| {
smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, &style, &colors, outline.as_deref());
});
let (r, _) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true);
egui::Popup::menu(&r).show(|ui| {
if ui.button("Convert to Text").clicked() {
run(app, "smartArt.toText", json!({"id": gid}));
}
if ui.button("Convert to Shapes").clicked() {
run(app, "smartArt.toShapes", json!({"id": gid}));
}
});
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());
});
}
let (main, arrow) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true);
if let Some(arrow) = arrow {
widgets::split_menu(&main, &arrow, |ui| {
if ui.button("Convert to Text").clicked() {
run(app, "smartArt.toText", json!({"id": gid}));
}
if ui.button("Convert to Shapes").clicked() {
run(app, "smartArt.toShapes", json!({"id": gid}));
}
});
}
});
group(ui, |ui| {
let (r, _) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true);
egui::Popup::menu(&r).show(|ui| {
for (k, label) in deckcraft_engine::cmd::smartart::COLORS {
if matches!(k, "range23" | "accent1" | "accent1Grad") {
ui.separator();
let (main, arrow) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true);
if let Some(arrow) = arrow {
widgets::split_menu(&main, &arrow, |ui| {
for (k, label) in deckcraft_engine::cmd::smartart::COLORS {
if matches!(k, "range23" | "accent1" | "accent1Grad") {
ui.separator();
}
let on = colors == k || (colors.is_empty() && k == "colorful");
if ui.selectable_label(on, label).clicked() {
run(app, "smartArt.colors", json!({"id": gid, "colors": k}));
}
}
let on = colors == k || (colors.is_empty() && k == "colorful");
if ui.selectable_label(on, label).clicked() {
run(app, "smartArt.colors", json!({"id": gid, "colors": k}));
});
}
let (main, arrow) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true);
if let Some(arrow) = arrow {
widgets::split_menu(&main, &arrow, |ui| {
for (k, label) in deckcraft_engine::cmd::smartart::STYLES {
if k == "polished" {
ui.separator();
ui.label("3-D");
}
let on = style == k || (style.is_empty() && k == "flat");
if ui.selectable_label(on, label).clicked() {
run(app, "smartArt.style", json!({"id": gid, "style": k}));
}
}
}
});
let (r, _) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true);
egui::Popup::menu(&r).show(|ui| {
for (k, label) in deckcraft_engine::cmd::smartart::STYLES {
if k == "polished" {
ui.separator();
ui.label("3-D");
}
let on = style == k || (style.is_empty() && k == "flat");
if ui.selectable_label(on, label).clicked() {
run(app, "smartArt.style", json!({"id": gid, "style": k}));
}
}
});
});
}
});
arrange_group(app, ui);
}
@@ -3570,16 +3680,16 @@ fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) {
}) else {
return;
};
let key = egui::Id::new(("deckcraft-smooth-corners", id.0));
let key = egui::Id::unique(("deckcraft-smooth-corners", id.0));
let mut state: (f64, f64) = ui.data(|d| d.get_temp(key)).unwrap_or((radius.unwrap_or(0.0), 0.0));
if let Some(r) = radius {
state.0 = r;
}
let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some();
ui.horizontal(|ui| {
ui.label("Smooth Corners");
let label = ui.label("Smooth Corners");
let mut pct = state.1;
let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer());
let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()).labelled_by(label.id);
let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") };
if resp.changed() {
state.1 = pct;
@@ -3596,7 +3706,7 @@ fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) {
/// Insert Zoom dialogs do (a checklist of slides or sections).
fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) {
let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };
let id = egui::Id::new("insert_zoom_menu");
let id = egui::Id::unique("insert_zoom_menu");
let (mut kind, mut picked): (u8, Vec<usize>) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, vec![])));
ui.horizontal(|ui| {
for (k, label) in [(0u8, "Slide Zoom"), (1, "Section Zoom"), (2, "Summary Zoom")] {
@@ -3653,7 +3763,7 @@ fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) {
/// Compress Pictures options, as in PowerPoint's dialog: resolution, apply to this picture or
/// all, delete cropped areas. The choices last for the session.
fn compress_menu(app: &mut SlideApp, ui: &mut Ui) {
let id = egui::Id::new("compress_pictures_opts");
let id = egui::Id::unique("compress_pictures_opts");
let (mut ppi, mut only_selected, mut delete_cropped): (u32, bool, bool) = ui.data_mut(|d| d.get_temp(id).unwrap_or((220, true, true)));
ui.label(egui::RichText::new("Compression options").strong());
ui.checkbox(&mut only_selected, "Apply only to this picture");
+16 -12
View File
@@ -6,7 +6,7 @@
use std::collections::HashSet;
use deckcraft_model::Shape;
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2};
use egui::{Align2, CornerRadius, Id, Rect, Role, Sense, Stroke, Ui, WidgetInfo, pos2, vec2};
use serde_json::json;
use crate::SlideApp;
@@ -102,13 +102,14 @@ fn run(app: &mut SlideApp, id: &str, p: serde_json::Value) {
/// Each row is a widget with an id of its own (keyboard focus sits on the selected row).
fn row_id(id: u32) -> Id {
Id::new(("selection_pane_row", id))
Id::unique(("selection_pane_row", id))
}
/// Draw a small labelled icon button and return its interaction response.
fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label));
resp.widget_info(|| WidgetInfo::labeled(Role::Button, enabled, label));
if enabled && resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
}
@@ -117,6 +118,8 @@ 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 };
@@ -157,7 +160,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let resp = ui.interact(r, row_id(row.id), Sense::click_and_drag());
rects.push(r);
let sel = selected.contains(&row.id);
resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, sel, &row.name));
resp.widget_info(|| WidgetInfo::selected(Role::Button, true, sel, &row.name));
let painter = ui.painter_at(r);
if sel {
painter.rect_filled(r, CornerRadius::same(3), t.selected);
@@ -170,9 +173,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
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.id().with(("fold", row.id)), Sense::click());
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(WidgetType::Button, true, &label));
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 {
@@ -188,8 +191,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let name_rect = Rect::from_min_max(pos2(name_x, r.min.y + 2.0), pos2(eye.min.x - 6.0, r.max.y - 2.0));
let renaming = app.selpane.renaming.as_ref().filter(|(id, _)| *id == row.id).map(|(_, s)| s.clone());
if let Some(mut buf) = renaming {
let edit_id = ui.id().with(("rename", row.id));
let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::font(12.5)).margin(vec2(2.0, 1.0)));
let edit_id = ui.scope_id().with(("rename", row.id));
let out =
ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).hint_text("Name").font(theme::font(12.5)).margin(vec2(2.0, 1.0)));
if !app.selpane.rename_started {
out.request_focus();
if let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), edit_id) {
@@ -218,12 +222,12 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let ink = if row.dimmed { t.text_faint } else { t.text_dim };
icons::paint(&painter, eye, if row.hidden { Icon::EyeOff } else { Icon::Eye }, ink, false);
icons::paint(&painter, lock, if row.locked { Icon::Lock } else { Icon::Unlock }, ink, false);
let eye_resp = ui.interact(eye.expand(3.0), ui.id().with(("eye", row.id)), Sense::click());
let eye_resp = ui.interact(eye.expand(3.0), ui.scope_id().with(("eye", row.id)), Sense::click());
let eye_label = format!("{} {}", if row.hidden { "Show" } else { "Hide" }, row.name);
eye_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &eye_label));
let lock_resp = ui.interact(lock.expand(3.0), ui.id().with(("lock", row.id)), Sense::click());
eye_resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &eye_label));
let lock_resp = ui.interact(lock.expand(3.0), ui.scope_id().with(("lock", row.id)), Sense::click());
let lock_label = format!("{} {}", if row.locked { "Unlock" } else { "Lock" }, row.name);
lock_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &lock_label));
lock_resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &lock_label));
if eye_resp.clicked() {
run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden}));
} else if lock_resp.clicked() {
+268 -25
View File
@@ -13,6 +13,9 @@ use crate::SlideApp;
use crate::theme;
pub struct Show {
/// Monotonic identity of this show instance. Starting a show again (even at the same slide
/// and step) is a new instance, so a queued click cannot cross an ABA transition.
pub(crate) instance: u64,
pub state: ShowState,
pub reading: bool,
pub presenter: bool,
@@ -73,6 +76,10 @@ pub struct Show {
media_clock: Option<(usize, f64)>,
/// Media effects set off and not yet carried out: (shape, effect id).
media_cues: Vec<(ShapeId, String)>,
/// Hidden-logic time anchor: the last visible egui time and the monotonic instant of that
/// visible frame. `run_logic` does not refresh `InputState::time`, so this lets a hidden show
/// keep playing from wall time without a draw.
hidden_anchor: Option<(f64, web_time::Instant)>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -270,9 +277,48 @@ struct Transition {
dur: f64,
}
/// A copy of the show's clock origins for regressions that must not depend on wall time.
#[cfg(test)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ClockSnapshot {
pub step_start: f64,
pub slide_start: f64,
pub step_end: f64,
pub show_start: f64,
pub last_move: f64,
}
/// The parts of a show that change what a window-point event means. Coordinates are relative
/// to the whole viewport, so the step, end screen, reading/presenter layout, pen/grid/zoom modes
/// and blank screen all have to match the batch's target as well as the instance and slide.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ShowInputState {
pub instance: u64,
pub slide: usize,
pub step: usize,
pub ended: bool,
pub reading: bool,
pub presenter: bool,
pub grid: bool,
pub pen: bool,
pub laser: bool,
pub blank: Option<[u8; 4]>,
pub zoom: Option<[u32; 3]>,
pub zoom_arm: bool,
}
/// True unless the root viewport explicitly reports itself minimized or fully occluded.
/// Integrations that do not report visibility stay visible rather than freezing the show.
pub(crate) fn viewport_visible(ctx: &egui::Context) -> bool {
ctx.input(|i| i.viewport().visible() != Some(false))
}
impl Show {
/// Initialise show state at the requested slide with reading/presenter modes; an out-of-range
/// slide starts an ended show.
pub fn new(doc: &Presentation, from: usize, reading: bool, presenter: bool) -> Self {
Show {
instance: 0,
state: ShowState::start_at(doc, from),
reading,
presenter,
@@ -313,9 +359,123 @@ impl Show {
links: None,
media_clock: None,
media_cues: vec![],
hidden_anchor: None,
}
}
/// Identity of the show state a queued window-point event belongs to.
pub(crate) fn input_state(&self) -> ShowInputState {
let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 };
ShowInputState {
instance: self.instance,
slide: self.state.slide,
step: self.state.step,
ended: self.ended || self.state.ended,
reading: self.reading,
presenter: self.presenter,
grid: self.grid,
pen: self.pen,
laser: self.laser,
blank: self.blank.map(|c| [c.r(), c.g(), c.b(), c.a()]),
zoom: self.zoom.map(|z| [bits(z.nx), bits(z.ny), bits(z.scale)]),
zoom_arm: self.zoom_arm,
}
}
/// The show time for a hidden logic pass. `run_logic` leaves `InputState::time` at the last
/// visible frame, so anchor that value to the monotonic instant of the visible frame and
/// advance from wall time. Only a full visible UI pass re-anchors to egui's real frame time;
/// a visible logic-only pass must not reuse its stale input time as a new anchor.
pub(crate) fn hidden_logic_time(&self, visible_time: f64, now: web_time::Instant) -> f64 {
let (base, at) = self.hidden_anchor.unwrap_or((visible_time, now));
base + now.saturating_duration_since(at).as_secs_f64()
}
/// A visible UI pass drives the clocks through its real frame time and records the anchor a
/// later hidden logic tick advances from.
pub(crate) fn observe_visible_frame(&mut self, visible_time: f64, now: web_time::Instant) {
self.hidden_anchor = Some((visible_time, now));
}
/// Whether hidden logic is running a show that has already passed its first visible frame.
pub(crate) fn hidden_started(&self) -> bool {
self.started
}
/// Whether hidden logic still has time-driven work, so it must keep requesting wake-ups.
pub(crate) fn hidden_time_driven(&self, doc: &Presentation) -> bool {
self.started && !self.ended && (self.state.playing || self.trans.is_some() || self.autoplay || self.state.advance_after(doc).is_some())
}
/// Advance the show's own clocks and automatic steps on a hidden logic pass. Drawing is a
/// separate concern, so a minimized show keeps playing instead of inventing a pause policy.
/// Returns false when the show should close (preview ended).
pub(crate) fn advance_hidden(&mut self, doc: &Presentation, now: f64) -> bool {
self.tick_triggers(doc, now);
self.expire_goto(now);
if self.trans.as_ref().is_some_and(|trans| now - trans.start >= trans.dur.max(0.01)) {
self.trans = None;
}
let mut actions = Vec::new();
let mut keep = self.queue_clock_actions(doc, now, &mut actions);
for action in actions {
if !self.apply(action, doc, now) {
keep = false;
}
}
if self.state.playing {
let step = self.state.step;
self.cue_media(doc, step, now - self.step_start);
}
keep
}
/// Queue the step/auto-advance actions for `now`. Shared by the visible UI pass and the
/// hidden logic tick so both read the same clock. Returns false when a preview should close.
fn queue_clock_actions(&mut self, doc: &Presentation, now: f64, actions: &mut Vec<ShowAction>) -> bool {
let (step, playing) = (self.state.step, self.state.playing);
let t_step = if playing { now - self.step_start } else { 0.0 };
if playing {
let dur = self.timeline(doc).step_duration(step);
if t_step >= dur {
actions.push(self.state.step_done(doc));
self.step_end = now;
}
} else if self.trans.is_none() {
// Auto-advance (After: timings) or autoplay preview. An automatic first step is started
// by `ShowState` as the slide appears; starting it here too replayed it after Back (#844).
let idle = now - self.step_end.max(self.slide_start);
let cur_step = self.state.step;
let n_steps = self.timeline(doc).steps();
let step_due = self.autoplay && cur_step < n_steps && idle > 0.3;
let holding = self.grid || self.presenter_grid || self.zoom.is_some() || self.zoom_arm;
if !holding && (step_due || (!self.rehearse && self.state.auto_advance_due(doc, idle) && !self.ended)) {
actions.push(self.state.next(doc));
} else if self.preview_only && cur_step >= n_steps && idle > 0.8 {
return false;
}
}
true
}
/// The clock origins used by the show, for deterministic hidden/resume regressions.
#[cfg(test)]
pub(crate) fn clock_snapshot(&self) -> ClockSnapshot {
ClockSnapshot {
step_start: self.step_start,
slide_start: self.slide_start,
step_end: self.step_end,
show_start: self.show_start,
last_move: self.last_move,
}
}
/// Presenter elapsed seconds at `now`, using the same clock as the view.
#[cfg(test)]
pub(crate) fn presenter_elapsed(&self, now: f64) -> f64 {
self.timer.elapsed(now)
}
/// True while the pointer moved in the last 2.5 s; the media controls and GIF button hide after that.
fn pointer_awake(&self, now: f64) -> bool {
now - self.last_move < 2.5
@@ -671,6 +831,8 @@ fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64,
keep
}
/// Draw and advance the active show, handling hidden frames, timing, input, transitions, and
/// presenter/reading presentation.
pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
let ctx = ui.ctx().clone();
let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else {
@@ -679,10 +841,31 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
};
let now = ctx.input(|i| i.time);
let Some(mut show) = app.show.take() else { return };
if !viewport_visible(&ctx) {
// 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);
}
}
app.show = Some(show);
return;
}
show.observe_visible_frame(now, web_time::Instant::now());
if !show.started {
show.started = true;
show.slide_start = now;
show.step_start = now;
// The first step is settled at the show's own start: every clock origin is a real frame
// time, so hiding and resuming shifts all of them by the same interval.
show.step_end = now;
show.show_start = now;
show.timer = PresenterTimer::start(now);
show.last_move = now;
@@ -798,28 +981,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
keep = false;
}
}
// Step clock.
let (step, playing) = (show.state.step, show.state.playing);
let t_step = if playing { now - show.step_start } else { 0.0 };
if playing {
let dur = show.timeline(&doc).step_duration(step);
if t_step >= dur {
actions.push(show.state.step_done(&doc));
show.step_end = now;
}
} else if show.trans.is_none() {
// Auto-advance (After: timings) or autoplay preview. An automatic first step is started
// by `ShowState` as the slide appears; starting it here too replayed it after Back (#844).
let idle = now - show.step_end.max(show.slide_start);
let cur_step = show.state.step;
let n_steps = show.timeline(&doc).steps();
let step_due = show.autoplay && cur_step < n_steps && idle > 0.3;
let holding = show.grid || show.presenter_grid || show.zoom.is_some() || show.zoom_arm;
if !holding && (step_due || (!show.rehearse && show.state.auto_advance_due(&doc, idle) && !show.ended)) {
actions.push(show.state.next(&doc));
} else if show.preview_only && cur_step >= n_steps && idle > 0.8 {
keep = false;
}
// Step clock, shared with the hidden logic tick.
if !show.queue_clock_actions(&doc, now, &mut actions) {
keep = false;
}
for a in actions {
if !show.apply(a, &doc, now) {
@@ -1094,7 +1258,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
// Pen.
let resp = ui.interact(full, ui.id().with("show_pen"), if show.pen { Sense::drag() } else { Sense::hover() });
let resp = ui.interact(full, ui.scope_id().with("show_pen"), if show.pen { Sense::drag() } else { Sense::hover() });
if show.pen {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Pen"));
}
if show.pen {
if resp.drag_started() {
show.strokes.push(vec![]);
@@ -1931,6 +2098,7 @@ fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usi
Some(tex)
}
/// Render the presenter window and set `keep` false when the user ends or closes the show.
fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep: &mut bool) {
let cur = show.state.slide;
let next = show.state.peek_next(doc);
@@ -2019,7 +2187,7 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
if show.presenter_grid {
// The grid fills this window; the audience keeps seeing the current slide.
paint_grid(ui.painter(), ui.ctx(), show, doc, avail);
let hit = ui.interact(avail, ui.id().with("presenter_grid"), Sense::click());
let hit = ui.interact(avail, ui.scope_id().with("presenter_grid"), Sense::click());
if hit.clicked()
&& let Some(p) = hit.interact_pointer_pos()
&& let Some(n) = see_all_hit(avail, grid_indices(doc.slides.len()).len(), slide_aspect(doc), p)
@@ -2045,7 +2213,8 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no
ui.painter().rect_stroke(mark, CornerRadius::ZERO, Stroke::new(2.0, Color32::WHITE), egui::StrokeKind::Inside);
}
if show.zoom_arm {
let hit = ui.interact(cr, ui.id().with("presenter_zoom"), Sense::click());
let hit = ui.interact(cr, ui.scope_id().with("presenter_zoom"), Sense::click());
hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Zoom"));
if hit.clicked()
&& let Some(p) = hit.interact_pointer_pos()
&& cr.width() > 1.0
@@ -2788,6 +2957,34 @@ mod tests {
assert_eq!(font_of("Agenda"), theme::font(24.0));
}
/// Hidden logic keeps playing from wall time even though `run_logic` leaves egui's input
/// time at the last visible frame. A visible pass re-anchors instead of shifting a pause.
#[test]
fn hidden_logic_advances_the_show_and_visible_frames_reanchor() {
let mut s = deckcraft_engine::Session::with_new();
s.execute("slide.new", &json!({})).unwrap();
let doc = s.active().unwrap().doc.clone();
let mut show = Show::new(&doc, 0, false, true);
show.started = true;
show.step_start = 10.0;
show.slide_start = 10.0;
show.step_end = 10.0;
show.show_start = 10.0;
show.last_move = 10.0;
show.timer = PresenterTimer::start(10.0);
let t0 = web_time::Instant::now();
show.observe_visible_frame(10.0, t0);
assert_eq!(show.hidden_logic_time(10.0, t0), 10.0, "the first hidden tick starts at the last visible time");
let hidden = show.hidden_logic_time(10.0, t0 + std::time::Duration::from_secs(2));
assert!((hidden - 12.0).abs() < 1e-9, "hidden logic advances the show time: {hidden}");
assert!((show.presenter_elapsed(hidden) - 2.0).abs() < 1e-9, "presenter time keeps running");
assert!((show.clock_snapshot().step_start - 10.0).abs() < 1e-9, "the clock origin is not shifted");
let later = show.hidden_logic_time(10.0, t0 + std::time::Duration::from_secs(5));
assert!((later - 15.0).abs() < 1e-9, "repeated hidden ticks do not stack a pause: {later}");
show.observe_visible_frame(20.0, t0 + std::time::Duration::from_secs(10));
assert_eq!(show.hidden_logic_time(20.0, t0 + std::time::Duration::from_secs(10)), 20.0, "a visible frame re-anchors");
}
#[test]
fn presenter_timer_pauses_resets_and_notes_size_clamps() {
let mut t = PresenterTimer::start(10.0);
@@ -2917,4 +3114,50 @@ mod tests {
let st = &harness.state().show.as_ref().unwrap().state;
assert_eq!(st.slide, 0, "five Backs leave slide 2 (step {}, playing {})", st.step, st.playing);
}
/// Click-target state follows show instance, reading/presenter modes, grid, and other layout
/// changes instead of retaining a stale target.
#[test]
fn input_state_tracks_the_modes_that_move_show_click_targets() {
let session = deckcraft_engine::Session::with_new();
let doc = &session.active().unwrap().doc;
let mut show = Show::new(doc, 0, false, false);
let base = show.input_state();
assert_eq!(show.input_state(), base);
show.instance += 1;
assert_ne!(show.input_state(), base, "a new show instance is a new input target");
show.instance = base.instance;
show.reading = true;
assert_ne!(show.input_state(), base, "the reading layout moves the slide");
show.reading = false;
show.presenter = true;
assert_ne!(show.input_state(), base, "presenter view changes the available area");
show.presenter = false;
show.grid = true;
assert_ne!(show.input_state(), base, "See All Slides replaces the slide with thumbnails");
show.grid = false;
show.pen = true;
assert_ne!(show.input_state(), base, "the pen changes what a drag means");
show.pen = false;
show.laser = true;
assert_ne!(show.input_state(), base, "the laser changes what a drag means");
show.laser = false;
show.zoom_arm = true;
assert_ne!(show.input_state(), base, "armed zoom changes what a click means");
show.zoom_arm = false;
show.zoom = Some(Zoom::at(0.25, 0.75));
assert_ne!(show.input_state(), base, "a zoomed slide has a different click mapping");
show.zoom = None;
show.blank = Some(Color32::BLACK);
assert_ne!(show.input_state(), base, "a blank screen has no slide click targets");
}
}
+21 -15
View File
@@ -3,7 +3,7 @@
use std::collections::HashSet;
use deckcraft_model::{Presentation, SlideId};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2};
use egui::{Align2, Color32, CornerRadius, Rect, Role, Sense, Stroke, Ui, WidgetInfo, pos2, vec2};
use serde_json::json;
use crate::SlideApp;
@@ -70,12 +70,14 @@ struct Header<'a> {
first: Option<u32>,
}
/// Render one slide-sorter section header, handle its collapse/drag interactions, and return the
/// occupied rectangle.
fn section_header(app: &mut SlideApp, ui: &mut Ui, h: &Header<'_>) -> Rect {
let t = Tokens::get(ui.ctx());
let width = ui.available_width();
let (r, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover());
let resp = ui.interact(r, ui.id().with(("sorter-section", h.uid, h.index)), Sense::click_and_drag());
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, h.name));
let resp = ui.interact(r, ui.scope_id().with(("sorter-section", h.uid, h.index)), Sense::click_and_drag());
resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, h.name));
let open = h.first.is_none_or(|id| !app.sorter.collapsed.contains(&(h.uid, id)));
let dragging = app.sorter.drag.is_some_and(|(from, _)| from == h.index);
let bg = if dragging {
@@ -88,15 +90,16 @@ fn section_header(app: &mut SlideApp, ui: &mut Ui, h: &Header<'_>) -> Rect {
let painter = ui.painter_at(r);
painter.rect_filled(r, CornerRadius::same(4), bg);
let chev = Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(24.0, 24.0));
let cresp = ui.interact(chev, ui.id().with(("sorter-fold", h.uid, h.index)), Sense::click());
let cresp = ui.interact(chev, ui.scope_id().with(("sorter-fold", h.uid, h.index)), Sense::click());
let fold = format!("{} {}", if open { "Collapse" } else { "Expand" }, h.name);
cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &fold));
cresp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &fold));
icons::paint(&painter, chev.shrink(4.0), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, h.first.is_none());
let renaming = app.sorter.renaming.clone().filter(|(uid, index, _)| *uid == h.uid && *index == h.index).map(|(_, _, text)| text);
let name_rect = Rect::from_min_max(pos2(r.min.x + 40.0, r.min.y + 4.0), pos2(r.max.x - 56.0, r.max.y - 4.0));
if let Some(mut buf) = renaming {
let edit_id = ui.id().with(("sorter-rename", h.uid, h.index));
let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::bold(13.0)).margin(vec2(4.0, 2.0)));
let edit_id = ui.scope_id().with(("sorter-rename", h.uid, h.index));
let out =
ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).hint_text("Name").font(theme::bold(13.0)).margin(vec2(4.0, 2.0)));
let already = app.sorter.rename_started;
if !already {
out.request_focus();
@@ -199,7 +202,7 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
let Some(&i) = g.indices.get(row * cols + c) else { break };
let (cell, resp) = ui.allocate_exact_size(vec2(g.tw + 36.0, g.th + 36.0), Sense::click_and_drag());
let label = format!("Slide {}", i + 1);
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label));
resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &label));
let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp);
rects.push((i, cell));
let p = ui.painter();
@@ -400,8 +403,8 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect));
// A draft belongs to this document and to the notes it started from (#413): another document,
// a reorder or an undo that changes them drops it.
let id = egui::Id::new(("notespage", st.uid, idx));
let edit_id = egui::Id::new(("notespage-edit", idx));
let id = egui::Id::unique(("notespage", st.uid, idx));
let edit_id = egui::Id::unique(("notespage-edit", idx));
let focused = child.ctx().memory(|m| m.has_focus(edit_id));
let notes_of = |app: &SlideApp| app.session.active().and_then(|st| st.doc.slides.get(idx)).map(|s| s.notes.clone()).unwrap_or_default();
let notes = notes_of(app);
@@ -452,7 +455,7 @@ pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) {
let origin = ui.cursor().top();
let margin = 300.0;
for (i, s) in doc.slides.iter().enumerate() {
let hid = egui::Id::new(("ol-height", s.id.0));
let hid = egui::Id::unique(("ol-height", s.id.0));
let known: Option<f32> = ui.data(|d| d.get_temp(hid));
let h = known.unwrap_or_else(|| outline_estimate(s));
let y = ui.cursor().top() - origin;
@@ -493,10 +496,12 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck
ui.label(egui::RichText::new(format!("{}", i + 1)).color(t.text_dim));
let (r, _) = ui.allocate_exact_size(vec2(14.0, 10.0), Sense::hover());
ui.painter().rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, if i == cur { t.accent } else { t.text_dim }), egui::StrokeKind::Inside);
let id = egui::Id::new(("ol-title", s.id.0));
let id = egui::Id::unique(("ol-title", s.id.0));
let cur_title = s.title();
let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_title.clone()));
let resp = ui.add(egui::TextEdit::singleline(&mut v).font(theme::bold(14.0)).frame(egui::Frame::NONE).desired_width(f32::INFINITY));
let resp = ui.add(
egui::TextEdit::singleline(&mut v).hint_text("Slide title").font(theme::bold(14.0)).frame(egui::Frame::NONE).desired_width(f32::INFINITY),
);
if resp.gained_focus() {
let _ = app.run("slide.go", json!({"index": i}));
}
@@ -517,12 +522,13 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck
if tb.is_empty() {
continue;
}
let id = egui::Id::new(("ol-body", s.id.0, sh.id.0));
let id = egui::Id::unique(("ol-body", s.id.0, sh.id.0));
let cur_text: String = tb.paragraphs.iter().map(|p| format!("{}{}", "\t".repeat(p.level as usize), p.text())).collect::<Vec<_>>().join("\n");
let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_text.clone()));
ui.horizontal(|ui| {
ui.add_space(36.0);
let resp = ui.add(egui::TextEdit::multiline(&mut v).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1));
let resp = ui
.add(egui::TextEdit::multiline(&mut v).hint_text("Slide text").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1));
if resp.gained_focus() {
let _ = app.run("slide.go", json!({"index": i}));
}
+136
View File
@@ -0,0 +1,136 @@
//! Split controls on real ribbon callers: a dropdown-only control must not expose a dead named
//! half, while Group and Rotate keep a real main action beside their options arrow.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use deckcraft_engine::Session;
use deckcraft_model::ShapeId;
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::{Services, SlideApp};
/// Two rectangles selected on a blank slide, so all the arrange controls are enabled.
fn harness(tab: &str) -> (Harness<'static, SlideApp>, [ShapeId; 2]) {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let mut ids = [ShapeId(0); 2];
for (i, x) in [100.0, 300.0].iter().enumerate() {
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 100.0, 80.0, 60.0]})).unwrap();
ids[i] = ShapeId(r["id"].as_u64().unwrap() as u32);
}
s.execute("selection.select", &json!({"ids": [ids[0].0, ids[1].0]})).unwrap();
let mut app = SlideApp::new(s, Services::default());
app.ui.tab = tab.into();
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 900.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(3);
(h, ids)
}
/// Click the accessible button or chevron with the given name and run three harness steps.
fn click(h: &mut Harness<'static, SlideApp>, name: &str) {
h.get_by_role_and_label(Role::Button, name).click();
h.run_steps(3);
}
/// Click each named half, see the same menu, toggle it closed from that same half, then repeat on
/// the other half. The old callers wired only one half, so one full click produced no menu.
fn both_halves_open_same_menu(tab: &str, name: &str, item: &str) {
let (mut h, _) = harness(tab);
for half in [name.to_string(), format!("{name} options")] {
assert!(h.query_by_label(item).is_none(), "{tab}: the {half} menu starts closed");
click(&mut h, &half);
assert!(h.query_by_label(item).is_some(), "{tab}: clicking {half} opens the shared menu");
click(&mut h, &half);
assert!(h.query_by_label(item).is_none(), "{tab}: clicking {half} again closes the same menu");
}
}
/// Both the Home Layout label and chevron open and toggle the same layout menu.
#[test]
fn home_layout_opens_the_layout_menu_from_its_label_and_arrow() {
both_halves_open_same_menu("Home", "Layout", "Title Slide");
}
/// Keyboard entry must recognize either named half as the menu's origin. In particular, Up
/// enters at the last item rather than letting spatial focus navigation escape into the ribbon.
#[test]
fn home_section_menu_enters_wraps_and_closes_from_either_keyboard_trigger() {
let (mut h, _) = harness("Home");
for trigger in ["Section", "Section options"] {
for (entry, first, wrapped) in
[(egui::Key::ArrowUp, "Remove All Sections", "Add Section"), (egui::Key::ArrowDown, "Add Section", "Remove All Sections")]
{
h.get_by_role_and_label(Role::Button, trigger).focus();
h.run_steps(1);
assert!(h.get_by_role_and_label(Role::Button, trigger).is_focused(), "{trigger}: the named half has focus");
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert!(h.query_by_role_and_label(Role::Button, "Add Section").is_some(), "{trigger}: Enter opens the menu");
h.key_press(entry);
h.run_steps(2);
assert!(h.get_by_role_and_label(Role::Button, first).is_focused(), "{trigger}: {entry:?} enters the menu");
// The opposite direction wraps at the edge, then the entry direction wraps back.
h.key_press(if entry == egui::Key::ArrowUp { egui::Key::ArrowDown } else { egui::Key::ArrowUp });
h.run_steps(2);
assert!(h.get_by_role_and_label(Role::Button, wrapped).is_focused(), "{trigger}: focus wraps inside the menu");
h.key_press(entry);
h.run_steps(2);
assert!(h.get_by_role_and_label(Role::Button, first).is_focused(), "{trigger}: focus wraps back");
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.query_by_role_and_label(Role::Button, "Add Section").is_none(), "{trigger}: Escape closes the menu");
}
}
}
/// Main and chevron halves of Shape Effects open the same effects menu on Home and Shape
/// Format.
#[test]
fn shape_effects_opens_the_effects_menu_from_its_label_and_arrow_on_both_tabs() {
both_halves_open_same_menu("Home", "Shape Effects", "Format Shape…");
both_halves_open_same_menu("Shape Format", "Shape Effects", "Format Shape…");
}
/// Text Fill's label and chevron both open its colour menu.
#[test]
fn shape_format_text_fill_opens_its_colour_menu_from_its_label_and_arrow() {
both_halves_open_same_menu("Shape Format", "Text Fill", "More Colors… #");
}
/// The Group main area executes grouping while only its chevron opens the group menu.
#[test]
fn shape_format_group_main_groups_and_its_arrow_opens_the_group_menu() {
let (mut h, _) = harness("Shape Format");
click(&mut h, "Group");
{
let d = h.state().session.active().unwrap();
assert_eq!(d.selection.shapes.len(), 1, "the main half grouped the two selected shapes");
let grouped = d.shape(d.selection.shapes[0]).unwrap();
assert!(grouped.is_group(), "the result is a group, not a menu choice");
}
click(&mut h, "Group options");
assert!(h.query_by_label("Ungroup").is_some(), "the arrow opens the Group actions");
}
/// The Rotate main area rotates while its chevron opens the rotate menu.
#[test]
fn shape_format_rotate_main_rotates_and_its_arrow_opens_the_rotate_menu() {
let (mut h, ids) = harness("Shape Format");
h.state_mut().session.execute("selection.select", &json!({"ids": [ids[0].0]})).unwrap();
h.run_steps(2);
click(&mut h, "Rotate");
let rot = h.state().session.active().unwrap().shape(ids[0]).unwrap().xfrm.unwrap().rot;
assert!((rot - 90.0).abs() < 1e-9, "the main half rotates right: {rot}");
click(&mut h, "Rotate options");
assert!(h.query_by_label("Rotate Left 90°").is_some(), "the arrow opens the Rotate actions");
}
+11 -9
View File
@@ -64,16 +64,9 @@ struct Done {
ranges: Vec<(Tag, usize, usize)>,
}
/// Return the shared application clock in milliseconds for spelling-squiggle animation timing.
fn now_ms() -> f64 {
#[cfg(target_arch = "wasm32")]
{
js_sys::Date::now()
}
#[cfg(not(target_arch = "wasm32"))]
{
static T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
T0.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0
}
crate::now_ms()
}
/// The checking itself, step by step: the newest job of each kind, a paragraph at a time, with
@@ -251,6 +244,15 @@ pub fn lang_name(tag: &str) -> String {
LANGS.iter().find(|(_, t)| t.eq_ignore_ascii_case(tag)).map_or_else(|| tag.to_string(), |(n, _)| n.to_string())
}
/// A language name the current interface fonts can actually draw: the native name when every
/// character has a glyph, otherwise the tag. On a machine without a Korean, Chinese or Hebrew
/// face, egui 0.37's strict missing-glyph policy would abort the frame instead of drawing boxes,
/// so state the fallback explicitly (and never skip the row).
pub fn display_lang_name(ctx: &egui::Context, tag: &str) -> String {
let name = lang_name(tag);
if ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(13.0), &name)) { name } else { tag.to_string() }
}
/// The flagged word (misspelt or repeated) of `pp` starting at `start`, with what the menu offers.
pub fn menu_word(pp: &Paragraph, default: &str, tag: Tag, start: usize) -> Option<MenuWord> {
let w = deckcraft_engine::spell::misspelt(pp, default, &[]).into_iter().find(|w| w.start == start)?;
+44 -17
View File
@@ -29,13 +29,13 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
let right_x = rect.max.x - 560.0;
// The selection's proofing language; a click opens Review › Language.
let (lang, no_proof) = crate::dialogs::selected_language(app);
let name = crate::squiggles::lang_name(&lang) + if no_proof { " (not checked)" } else { "" };
let name = crate::squiggles::display_lang_name(ui.ctx(), &lang) + if no_proof { " (not checked)" } else { "" };
let lx = (rect.min.x + 120.0).max(sr.max.x + 16.0);
let lclip = Rect::from_min_max(pos2(lx - 3.0, rect.min.y), pos2(right_x.max(lx - 3.0), rect.max.y));
let lr = p.with_clip_rect(lclip).text(pos2(lx, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim);
let hit = lr.expand(3.0).intersect(lclip);
if hit.is_positive() {
let resp = ui.interact(hit, ui.id().with("status_lang"), Sense::click()).on_hover_text("Language");
let resp = ui.interact(hit, ui.scope_id().with("status_lang"), Sense::click()).on_hover_text("Language");
resp.widget_info(|| named_with_value("Language", &name));
if resp.clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("language"));
@@ -70,9 +70,16 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
// Slider 10%–400% (log scale).
let mut z = app.ui.zoom.unwrap_or(app.canvas_scale).clamp(0.1, 4.0);
let mut zp = z * 100.0;
let s = right.add_sized(vec2(110.0, 18.0), egui::Slider::new(&mut zp, 10.0..=400.0).logarithmic(true).show_value(false));
// egui 0.37 gives a slider its own width and the theme's control height; ask for exactly
// those so the status row stays consistent with every other control without hard-coding
// a size the slider would override anyway.
let slider_size = vec2(right.spacing().slider_width, right.spacing().interact_size.y);
let s = right.add_sized(slider_size, egui::Slider::new(&mut zp, 10.0..=400.0).logarithmic(true).show_value(false));
// The status bar has no room for a visible "Zoom" label; name the slider for assistive
// technology only, leaving its geometry unchanged. Its value is the percentage shown.
let shown = format!("{}%", zp.round());
s.widget_info(|| egui::WidgetInfo { current_text_value: Some(shown.clone()), ..egui::WidgetInfo::slider(true, f64::from(zp), "Zoom") });
let enabled = s.enabled();
s.widget_info(|| egui::WidgetInfo { current_text_value: Some(shown.clone()), ..egui::WidgetInfo::slider(enabled, f64::from(zp), "Zoom") });
if s.changed() {
z = zp / 100.0;
app.ui.zoom = Some(z);
@@ -108,16 +115,17 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
});
}
/// Draw a small status icon with a tooltip and return its interaction response.
fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response {
let resp = icon_button(ui, icon, on);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip));
resp.on_hover_text(tip)
}
/// A view button: screen readers hear its name and whether its view is the one shown.
fn view_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response {
let resp = icon_button(ui, icon, on);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, tip));
resp.on_hover_text(tip)
}
@@ -133,27 +141,31 @@ fn icon_button(ui: &mut Ui, icon: Icon, on: bool) -> egui::Response {
/// A clickable status-bar text: screen readers hear `label` as its name and `text` as its value.
fn named_with_value(label: &str, text: &str) -> egui::WidgetInfo {
egui::WidgetInfo { current_text_value: Some(text.to_string()), ..egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label) }
egui::WidgetInfo { current_text_value: Some(text.to_string()), ..egui::WidgetInfo::labeled(egui::Role::Button, true, label) }
}
/// Painted status-bar text: screen readers read `text` from a label over `rect`.
fn shown_text(ui: &Ui, rect: Rect, id: &str, text: &str) {
if rect.is_positive() {
let resp = ui.interact(rect, ui.id().with(id), Sense::hover());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
let resp = ui.interact(rect, ui.scope_id().with(id), Sense::hover());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text));
}
}
/// Draw a labelled status icon, painting the text directly and exposing the toggle state as its
/// accessible selected state.
fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let w = 22.0 + ui.fonts_mut(|f| f.layout_no_wrap(label.into(), theme::font(12.0), t.text).size().x) + 8.0;
let (r, resp) = ui.allocate_exact_size(vec2(w, 20.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, label));
if on || resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), if on { t.selected } else { t.hover });
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 11.0, r.center().y), vec2(15.0, 15.0)), icon, t.text_dim, false);
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, theme::font(12.0), t.text_dim);
// The visible label is painted directly, so name the interactive control explicitly and
// expose the toggle state as selected: a screen reader hears "Notes, pressed" not "Notes".
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, label));
resp
}
@@ -172,7 +184,7 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
let (idx, uid) = (st.selection.slide, st.uid);
// Save an edited draft once the field has lost focus or another slide is shown (before
// anything reloads it), to the slide it was written on.
let id = egui::Id::new("notes_edit");
let id = egui::Id::unique("notes_edit");
let focused = ui.ctx().memory(|m| m.has_focus(id));
if app.notes_buf.2.edited && (!focused || app.notes_buf.0 != uid || app.notes_buf.1 != idx) {
app.notes_buf.2.edited = false;
@@ -298,13 +310,15 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use deckcraft_engine::Session;
use egui::accesskit::{Role, Toggled};
use egui::{Event, Modifiers, PointerButton};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
use serde_json::json;
use crate::{Services, SlideApp, ViewMode};
@@ -327,6 +341,12 @@ mod tests {
);
h.run_steps(3);
assert!(!h.state().ui.notes);
{
let mut notes = h.query_all_by_role_and_label(Role::Button, "Notes");
let button = notes.next().expect("the Notes button is there");
assert!(notes.next().is_none(), "exactly one Notes button, not a duplicated label target");
assert_eq!(button.accesskit_node().toggled(), Some(Toggled::False), "the Notes button reports its off state to screen readers");
}
// The controls on the right start 560 points from the bar's right edge, which is 8 points
// inside the window (x 132); the Notes button is the leftmost of them. Its position depends
// on the interface font's widths, so the clicks use the position it was laid out at.
@@ -347,6 +367,11 @@ mod tests {
assert!(!h.state().ui.notes, "the click in the gap did not reach the Notes button");
click(&mut h, notes.center());
assert!(h.state().ui.notes, "the click reached the Notes button");
assert_eq!(
h.get_by_role_and_label(egui::Role::Button, "Notes").accesskit_node().toggled(),
Some(Toggled::True),
"the open notes pane is exposed as a pressed Notes button"
);
assert!(h.state().dialog.is_none(), "the Language dialog stayed closed");
}
@@ -354,8 +379,6 @@ mod tests {
/// hear which view button is on, and read the slide counter and the status message.
#[test]
fn status_bar_controls_have_names() {
use egui_kittest::kittest::NodeT;
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
@@ -364,8 +387,12 @@ mod tests {
SlideApp::new(Session::with_new(), Services::default()),
);
h.run_steps(3);
let status_y = h.get_by_role_and_label(Role::Button, "Fit slide to current window").rect().center().y;
for name in ["Fit slide to current window", "Zoom In", "Zoom Out", "Slide Show", "Reading View"] {
h.get_by_role_and_label(Role::Button, name);
// The ribbon also has a "Slide Show" control. Require exactly one named button
// in the Fit button's laid-out status row, allowing the app's UI zoom.
let buttons = h.get_all_by_role_and_label(Role::Button, name).filter(|n| n.rect().y_range().contains(status_y)).count();
assert_eq!(buttons, 1, "one {name} button in the status bar");
}
let pct = format!("{}%", (h.state().canvas_scale * 100.0).round());
assert_eq!(h.get_by_role_and_label(Role::Button, "Zoom level").value().as_deref(), Some(pct.as_str()));
+184 -2
View File
@@ -189,7 +189,14 @@ pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) ->
for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() {
let wanted = (f.family == "Inter" && (f.style == "Regular" || f.style == "SemiBold" || f.style == "Bold"))
|| (f.family == "BIZ UDPGothic" && f.style == "Regular")
|| (f.family == "Noto Sans Arabic");
// Exact craft-fonts family names: the Korean face is "NanumGothic" (no space), and
// the symbol faces are "Noto Sans Symbols" / "Noto Sans Symbols 2". A near-miss here
// would leave Hangul and the ◉ laser toggle without a glyph. Hebrew backs the
// Language dialog's native "עברית" label where no system font supplies it.
|| matches!(f.family, "Noto Sans Arabic" | "Noto Sans Hebrew" | "NanumGothic" | "Noto Sans Symbols" | "Noto Sans Symbols 2")
// This opt-in native face follows the existing families. Keep per-character
// fallback and Latin metrics; no Chinese payload enters the wasm allowlist.
|| (cfg!(not(target_arch = "wasm32")) && f.family == "LXGW WenKai Lite" && f.style == "Regular");
if !wanted {
continue;
}
@@ -207,6 +214,15 @@ pub fn font_definitions(extra: &[std::sync::Arc<deckcraft_fonts::FontFace>]) ->
let mut bold: Vec<String> = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect();
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
defs.families.insert(FontFamily::Name("bold".into()), bold);
// Hack is already bundled for Monospace and covers the interface's arrows and triangles.
// Keep it after every proportional face so ordinary text metrics stay unchanged.
if defs.font_data.contains_key("Hack") {
for chain in defs.families.values_mut() {
if !chain.iter().any(|name| name == "Hack") {
chain.push("Hack".into());
}
}
}
add_fallback_faces(&mut defs, extra);
// The Japanese interface face (the first one when Inter isn't embedded) hints its en and em
// dashes, bars thinner than a pixel, down to nothing, so they drew as gaps in interface text;
@@ -251,10 +267,15 @@ pub fn to_color32(c: deckcraft_color::Rgba) -> Color32 {
#[cfg(test)]
mod tests {
use super::*;
/// Each character of `text` laid out in the interface fonts at interface sizes, and whether it
/// has ink (a glyph image).
fn drawn(text: &str) -> Vec<(char, f32, bool)> {
let ctx = egui::Context::default();
// A replacement glyph also gets a nonempty atlas rectangle. Fail before layout when no
// font can draw a character, so `uv_rect` cannot mistake tofu for real ink.
ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic);
ctx.set_fonts(super::font_definitions(&[]));
let mut o = ctx.run_ui(Default::default(), |_| {});
o.textures_delta.clear();
@@ -262,7 +283,11 @@ mod tests {
ctx.fonts_mut(|f| {
for size in [11.0, 12.0, 13.0, 16.0] {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name("bold".into())] {
let g = f.layout_no_wrap(text.to_string(), egui::FontId::new(size, family), egui::Color32::WHITE);
let font = egui::FontId::new(size, family);
for c in text.chars() {
assert!(f.has_glyph(&font, c), "no font supplies {c:?} at {size} px in {font:?}");
}
let g = f.layout_no_wrap(text.to_string(), font, egui::Color32::WHITE);
for gl in g.rows.iter().flat_map(|r| &r.glyphs) {
out.push((gl.chr, size, gl.uv_rect.size.x > 0.0 && gl.uv_rect.size.y > 0.0));
}
@@ -274,6 +299,163 @@ mod tests {
out
}
/// The UI font library supplies default text fonts and an explicit monochrome emoji face,
/// independent of kittest defaults.
#[test]
fn the_ui_library_has_default_fonts_and_explicit_monochrome_emoji_for_tests() {
// `egui`'s `default_fonts` is no longer a default feature and kittest's own dev
// dependencies do not propagate. This library enables it (and the test build enables
// `monochrome_emoji_fonts`) explicitly.
let defs = FontDefinitions::default();
assert!(defs.font_data.contains_key("Hack"), "default_fonts must be enabled by deckcraft-ui-egui itself");
assert!(defs.font_data.contains_key("Ubuntu-Light"));
assert!(defs.font_data.contains_key("NotoEmoji-Regular"), "monochrome_emoji_fonts is an explicit test feature");
assert!(defs.families.contains_key(&FontFamily::Proportional));
// The production font policy still owns the UI families: craft faces first, then egui's.
let ours = super::font_definitions(&[]);
assert!(ours.families.contains_key(&FontFamily::Name("bold".into())));
assert!(ours.font_data.contains_key("Hack"));
}
/// When the Chinese face is present (required builds guarantee it), exactly one LXGW WenKai
/// Lite Regular is embedded, and Chinese labels have glyph coverage and ink; builds without the
/// optional face skip this test.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn native_chinese_fallback_draws_initial_document_labels() {
let faces: Vec<_> = deckcraft_fonts::CRAFT_FONTS.iter().filter(|f| f.family == "LXGW WenKai Lite").collect();
if faces.is_empty() {
assert!(option_env!("CRAFT_FONTS_REQUIRED").is_none(), "required native font input must include the reviewed Chinese fallback");
eprintln!("optional Chinese input absent: bundled Chinese coverage was not qualified");
return;
}
assert_eq!(faces.len(), 1);
assert_eq!(faces[0].style, "Regular");
assert_eq!(faces[0].bytes.len(), 13_949_472);
let face = skrifa::FontRef::new(faces[0].bytes).unwrap();
use skrifa::MetadataProvider as _;
assert!(face.charmap().map('简').is_some(), "the approved fallback supplies the original missing glyph");
let defs = font_definitions(&[]);
for family in [FontFamily::Proportional, FontFamily::Name("bold".into())] {
let chain = &defs.families[&family];
let chinese = chain.iter().position(|name| name.contains("LXGW WenKai Lite")).unwrap();
let japanese = chain.iter().position(|name| name.contains("BIZ UDPGothic")).unwrap();
assert!(japanese < chinese, "retain existing glyph selection and Latin metrics");
assert_eq!(chain.iter().filter(|name| name.contains("LXGW WenKai Lite")).count(), 1);
}
let text = "简体中文演示文稿";
let ctx = egui::Context::default();
ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic);
ctx.set_fonts(defs);
let mut output = ctx.run_ui(Default::default(), |_| {});
output.textures_delta.clear();
ctx.fonts_mut(|fonts| {
for family in [FontFamily::Proportional, FontFamily::Name("bold".into())] {
let font = egui::FontId::new(13.0, family);
for c in text.chars() {
assert!(fonts.has_glyph(&font, c), "the embedded fallback does not supply {c:?} in {font:?}");
}
}
});
let ink = drawn(text);
assert_eq!(ink.len(), text.chars().count() * 4 * 2);
for (c, size, visible) in ink {
assert!(visible, "Chinese label {c:?} has no ink at {size} px");
}
}
/// When the face is present (required builds guarantee it), the native Hebrew fallback is exactly
/// one static Regular Noto Sans Hebrew face with glyph coverage and ink for language labels.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn native_hebrew_fallback_is_one_regular_face_and_draws_language_labels() {
let faces: Vec<_> = deckcraft_fonts::CRAFT_FONTS.iter().filter(|f| f.family == "Noto Sans Hebrew").collect();
if faces.is_empty() {
assert!(option_env!("CRAFT_FONTS_REQUIRED").is_none(), "required native font input must include Noto Sans Hebrew Regular");
eprintln!("optional font input absent: native Hebrew coverage was not qualified");
return;
}
assert_eq!(faces.len(), 1, "one static face, without variable named instances");
assert_eq!(faces[0].style, "Regular");
assert_eq!(faces[0].bytes.len(), 66_156, "use the approved full static Regular face");
use skrifa::raw::{ReadError, TableProvider};
let face = skrifa::FontRef::new(faces[0].bytes).unwrap();
assert_eq!(face.os2().unwrap().us_weight_class(), 400, "the embedded bytes must be Regular, not a renamed Thin face");
assert!(matches!(face.fvar(), Err(ReadError::TableIsMissing(_))), "embed one static face, not variable named instances");
let defs = super::font_definitions(&[]);
let names: Vec<_> = defs.font_data.keys().filter(|name| name.contains("Noto Sans Hebrew")).collect();
assert_eq!(names.len(), 1);
for family in [FontFamily::Proportional, FontFamily::Name("bold".into())] {
assert_eq!(defs.families[&family].iter().filter(|name| *name == names[0]).count(), 1);
}
let ctx = egui::Context::default();
ctx.set_fonts(defs);
let mut output = ctx.run_ui(Default::default(), |_| {});
output.textures_delta.clear();
ctx.fonts_mut(|fonts| {
for family in [FontFamily::Proportional, FontFamily::Name("bold".into())] {
let font = egui::FontId::new(13.0, family);
for c in "עבריתשלוםdeck42".chars() {
assert!(fonts.has_glyph(&font, c), "missing language-label glyph {c:?}");
}
}
});
// This checks ink and coverage only, not bidi ordering or GPOS mark positioning.
for (c, size, ink) in drawn("עבריתשלוםdeck42") {
assert!(ink, "{c:?} has no ink at {size} px");
}
}
/// Hebrew fallback preserves logical-order editing in mixed-direction text.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn native_hebrew_fallback_keeps_mixed_text_editing_in_logical_order() {
if !deckcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Hebrew") {
assert!(option_env!("CRAFT_FONTS_REQUIRED").is_none(), "required native Hebrew face is absent");
return;
}
let ctx = egui::Context::default();
ctx.set_fonts(super::font_definitions(&[]));
let id = egui::Id::unique("mixed-hebrew-label-edit");
let mut text = "deck שלום 42".to_owned();
let frame = |text: &mut String, events: Vec<egui::Event>| {
let output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(500.0, 100.0))),
events,
..Default::default()
},
|ui| {
ui.memory_mut(|m| m.request_focus(id));
ui.add(egui::TextEdit::singleline(text).id(id));
},
);
output.drop_without_applying_deltas();
};
frame(&mut text, vec![]);
// Character offsets remain logical even though the Hebrew glyphs are drawn right-to-left.
let mut state = egui::TextEdit::load_state(&ctx, id).unwrap();
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(7))));
egui::TextEdit::store_state(&ctx, id, state);
frame(&mut text, vec![egui::Event::Text("א".into())]);
assert_eq!(text, "deck שלאום 42");
let state = egui::TextEdit::load_state(&ctx, id).unwrap();
assert_eq!(state.cursor.char_range().unwrap().primary.index.0, 8);
frame(
&mut text,
vec![egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }],
);
assert_eq!(text, "deck שלום 42");
ctx.fonts_mut(|fonts| {
let galley = fonts.layout_no_wrap(text.clone(), egui::FontId::proportional(13.0), egui::Color32::WHITE);
let row = &galley.rows[0];
let x = |c| row.glyphs.iter().find(|g| g.chr == c).unwrap().pos.x;
assert!(x('ש') > x('ל') && x('ל') > x('ו') && x('ו') > x('ם'), "Hebrew paints in visual RTL order");
assert!(x('4') < x('2'), "the adjacent number keeps its LTR order");
});
frame(&mut text, vec![]); // consume the atlas updates from the explicit layout above
}
#[test]
fn light_checkboxes_and_slider_rails_stand_out_from_their_background() {
// An unchecked checkbox and a slider rail are filled with the inactive widget fill and
+4 -2
View File
@@ -8,6 +8,7 @@ use serde_json::json;
use crate::SlideApp;
use crate::theme::{self, Tokens};
/// Render the slide thumbnail pane, including section headers, selection, and scroll state.
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides);
@@ -32,7 +33,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
ui.add_space(20.0);
});
// Where the pane is scrolled to, for the control channel and tests (#917).
app.ui.thumbs_scroll = out.state.offset.y;
app.ui.thumbs_scroll = out.state.clamped_offset().y;
});
// Focus follows the last press: inside the pane keeps it, anywhere else (canvas, ribbon) drops it.
if let Some(pos) = ui.input(|i| if i.pointer.primary_pressed() { i.pointer.interact_pos() } else { None }) {
@@ -40,6 +41,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
}
}
/// Render the scrollable thumbnail list, scrolling the current slide into view when it changes.
fn list(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
@@ -53,7 +55,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
// along in the pane (#917). The scroll itself is minimal, only when the thumbnail leaves
// the view, as in PowerPoint, so the user's own scrolling stands; the first frame only
// records the slide.
let scroll_key = ui.id().with("thumb_scrolled_to");
let scroll_key = ui.scope_id().with("thumb_scrolled_to");
let slide_changed = ui.data_mut(|d| {
let last: Option<usize> = d.get_temp(scroll_key);
d.insert_temp(scroll_key, cur);
+3 -2
View File
@@ -39,6 +39,7 @@ pub fn note_missing_fonts(app: &mut SlideApp) {
}
}
/// Render and dismiss the active transient toast without disturbing the document.
pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
let Some(toast) = &app.toast else { return };
if crate::now_ms() - toast.at > SHOW_MS {
@@ -48,7 +49,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let text = toast.text.clone();
let mut close = false;
egui::Area::new(egui::Id::new("toast")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -44.0)).order(egui::Order::Foreground).show(ctx, |ui| {
egui::Area::new(egui::Id::unique("toast")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -44.0)).order(egui::Order::Foreground).show(ctx, |ui| {
egui::Frame::NONE
.fill(t.card)
.stroke(Stroke::new(1.0, t.card_border))
@@ -60,7 +61,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
ui.horizontal(|ui| {
ui.add(egui::Label::new(egui::RichText::new(&text).font(theme::font(13.0)).color(t.text)).wrap());
let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Close"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Close"));
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), t.hover);
}
+4 -1
View File
@@ -9,7 +9,7 @@ use std::collections::VecDeque;
use std::time::{Duration, Instant};
use deckcraft_engine::Session;
use egui::{Event, Key, Modifiers, MouseWheelUnit, Pos2, vec2};
use egui::{Event, Key, Modifiers, MouseWheelSource, MouseWheelUnit, Pos2, vec2};
use egui_kittest::Harness;
use crate::{Services, SlideApp};
@@ -83,6 +83,8 @@ fn harness() -> Harness<'static, SlideApp> {
h
}
/// Apply one deterministic random UI/document action and return a label describing it for the
/// soak log, which is used as context when a later assertion fails.
fn act(h: &mut Harness<'_, SlideApp>, r: &mut Rng) -> String {
match r.below(9) {
0..=2 => {
@@ -128,6 +130,7 @@ fn act(h: &mut Harness<'_, SlideApp>, r: &mut Rng) -> String {
delta: vec2(0.0, if r.below(2) == 0 { 3.0 } else { -3.0 }),
modifiers: Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: MouseWheelSource::Wheel,
});
format!("scroll {p:?}")
}
+36 -3
View File
@@ -18,11 +18,20 @@ use deckcraft_fonts::{FontDb, FontFace};
/// for the stock macOS CJK collections the font database picks: `AppleSDGothicNeo.ttc` (about
/// 55 MB, the only Hangul face) and `PingFang.ttc` (about 78 MB, Simplified Chinese).
const MAX_FACE_BYTES: usize = 128 << 20;
/// Aggregate bytes of every borrowed UI face. `MAX_FACE_BYTES` alone bounded one face but left
/// `faces` unbounded; three full-size CJK collections are enough for the supported scripts.
const MAX_AGGREGATE_BYTES: usize = 3 * MAX_FACE_BYTES;
fn accepts_face(byte_len: usize, color: bool) -> bool {
!color && byte_len <= MAX_FACE_BYTES
}
/// Accept a face only when checked addition stays within the aggregate interface-font byte
/// budget.
fn aggregate_accepts(total: usize, byte_len: usize) -> bool {
total.checked_add(byte_len).is_some_and(|n| n <= MAX_AGGREGATE_BYTES)
}
/// Faces borrowed for the interface, and the characters already looked at.
#[derive(Default)]
pub struct UiFonts {
@@ -81,9 +90,23 @@ impl UiFonts {
|c| FontDb::global().fallback_for_filtered(c, u32::MAX, |face| accepts_face(face.data().len(), face.is_color())),
)
});
if !new.is_empty() {
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
self.faces.extend(new);
let mut total: usize = self.faces.iter().map(|f| f.data().len()).sum();
let accepted: Vec<_> = new
.into_iter()
.filter(|f| {
let n = f.data().len();
if aggregate_accepts(total, n) {
total += n;
true
} else {
log::debug!("interface fallback face {} skipped: aggregate font budget", f.family);
false
}
})
.collect();
if !accepted.is_empty() {
log::debug!("interface fallback fonts: {}", accepted.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
self.faces.extend(accepted);
self.changed = true;
}
}
@@ -178,6 +201,16 @@ mod tests {
FontDb::global().face(family, "Regular")
}
/// The aggregate font budget accepts the exact cap and rejects overflow or over-budget
/// additions.
#[test]
fn aggregate_font_budget_is_bounded_and_checked() {
assert!(aggregate_accepts(0, MAX_FACE_BYTES), "the first face is accepted at the per-face limit");
assert!(aggregate_accepts(2 * MAX_FACE_BYTES, MAX_FACE_BYTES), "three faces reach the aggregate limit");
assert!(!aggregate_accepts(3 * MAX_FACE_BYTES, 1), "one byte over the aggregate limit is refused");
assert!(!aggregate_accepts(usize::MAX, 1), "overflow is refused, not wrapped");
}
#[test]
fn interface_face_policy_rejects_colour_and_oversize_faces() {
assert!(accepts_face(MAX_FACE_BYTES, false), "the documented size boundary remains accepted");
+276 -30
View File
@@ -31,28 +31,44 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> Respon
big_button_ex(ui, icon, label, enabled, false, false).0
}
/// Large button; with `split`, the label row is a separate dropdown target (returns its response).
/// Width of the dropdown target at the right of a large split button. It is reserved outside the
/// whole label area, so no visible line of a one- or two-line label can open the menu.
const SPLIT_ARROW_W: f32 = 14.0;
/// Return the chevron-only sub-rectangle at the right edge of a split button.
fn split_arrow_rect(rect: Rect) -> Rect {
Rect::from_min_max(pos2(rect.max.x - SPLIT_ARROW_W, rect.min.y + 38.0), rect.max)
}
/// Width of a large button: padding plus, for a split button, the reserved arrow strip.
fn big_button_width(text_w: f32, split: bool) -> f32 {
(text_w + 10.0 + if split { SPLIT_ARROW_W } else { 0.0 }).max(40.0)
}
/// Large button; with `split`, the label row ends in a separate dropdown target (returns its
/// response). Like Office's split buttons, the icon and the label text run the main command; only
/// the chevron at the end of the label row opens the menu.
pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: bool, selected: bool) -> (Response, Option<Response>) {
let t = Tokens::get(ui.ctx());
let font = theme::font(11.0);
let lines: Vec<&str> = wrap_label(label);
let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max);
let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0);
let w = big_button_width(text_w, split);
// The whole button runs the main command; the narrow strip at the end of the label row is the
// dropdown. A split button therefore has two named targets, not a third unnamed row target.
let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() });
// Named for screen readers and UI tests ("Ink to\nShape" reads "Ink to Shape").
let spoken = label.replace('\n', " ");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &spoken));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &spoken));
let painter = ui.painter_at(rect.expand(1.0));
let (main_rect, arrow_rect) = if split {
(Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max)))
} else {
(rect, None)
};
let main_resp =
if split { ui.interact(main_rect, resp.id.with("main"), if enabled { Sense::click() } else { Sense::hover() }) } else { resp.clone() };
let arrow_rect = split.then(|| split_arrow_rect(rect));
let main_resp = resp.clone();
let arrow_resp = arrow_rect.map(|r| ui.interact(r, resp.id.with("arrow"), if enabled { Sense::click() } else { Sense::hover() }));
if split && let Some(a) = &arrow_resp {
a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{spoken} options")));
}
if let Some(c) = bg(ui, &main_resp, selected, &t) {
painter.rect_filled(if split { main_rect } else { rect }, CornerRadius::same(4), c);
painter.rect_filled(rect, CornerRadius::same(4), c);
}
if let (Some(ar), Some(a)) = (arrow_rect, &arrow_resp)
&& let Some(c) = bg(ui, a, false, &t)
@@ -62,18 +78,19 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split:
let icon_rect = Rect::from_center_size(pos2(rect.center().x, rect.min.y + 19.0), vec2(30.0, 30.0));
icons::paint(&painter, icon_rect, icon, t.text, !enabled);
let col = if enabled { t.text } else { t.text_faint };
let label_rect = if split { Rect::from_min_max(rect.min, pos2(rect.max.x - SPLIT_ARROW_W, rect.max.y)) } else { rect };
let mut y = rect.min.y + 39.0;
for (i, l) in lines.iter().enumerate() {
let last = i + 1 == lines.len();
for l in &lines {
let label_w = ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), col).size().x);
let extra = if split && last { 9.0 } else { 0.0 };
let x = rect.center().x - (label_w + extra) / 2.0;
let x = label_rect.center().x - label_w / 2.0;
painter.text(pos2(x, y), Align2::LEFT_TOP, l, font.clone(), col);
if split && last {
chevron(&painter, pos2(x + label_w + 5.0, y + 7.0), 3.0, col);
}
y += 12.5;
}
if split {
// The chevron sits in the reserved strip beside the label's last line, never on its text.
let chevron_y = rect.min.y + 39.0 + 12.5 * lines.len().saturating_sub(1) as f32 + 7.0;
chevron(&painter, pos2(rect.max.x - 7.0, chevron_y), 3.0, col);
}
(if split { main_resp } else { resp }, arrow_resp)
}
@@ -113,15 +130,33 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selecte
small_button_ex(ui, icon, label, enabled, selected, false).0
}
/// Draw a small named button, optionally with a separately named options chevron, and return
/// both click targets.
pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selected: bool, split: bool) -> (Response, Option<Response>) {
small_button_named(ui, icon, label, if label.is_empty() { "Options" } else { label }, enabled, selected, split)
}
/// A small button whose accessible name is independent of its visible label. Split arrows are
/// named after their own command, not a shared "More options", so assistive technology can tell
/// Bullets, Numbering and the shape-format controls apart.
pub fn small_button_named(
ui: &mut Ui,
icon: Icon,
label: &str,
name: &str,
enabled: bool,
selected: bool,
split: bool,
) -> (Response, Option<Response>) {
let t = Tokens::get(ui.ctx());
let font = theme::font(12.0);
let tw = if label.is_empty() { 0.0 } else { ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0 };
let arrow_w = if split { 12.0 } else { 0.0 };
let w = 24.0 + tw + arrow_w;
let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), if enabled { Sense::click() } else { Sense::hover() });
if !label.is_empty() {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
// As in `big_button_ex`, a split row is two targets and must not add a third named node.
let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), if enabled && !split { Sense::click() } else { Sense::hover() });
if !name.is_empty() && !split {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, name));
}
let painter = ui.painter_at(rect);
let main_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - arrow_w, rect.max.y));
@@ -134,6 +169,12 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele
if enabled { Sense::click() } else { Sense::hover() },
)
});
if split {
main_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, name));
if let Some(a) = &arrow_resp {
a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{name} options")));
}
}
if let Some(c) = bg(ui, &main_resp, selected, &t) {
painter.rect_filled(main_rect, CornerRadius::same(4), c);
}
@@ -152,6 +193,54 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele
(if split { main_resp } else { resp }, arrow_resp)
}
/// Open one menu from either half of a split control that has no separate main action.
///
/// Both halves keep their own names for assistive technology, but the popup has one stable id
/// and one anchor spanning the complete painted control. Clicking the icon/label or the chevron
/// therefore opens and closes the same menu, and neither named target is a dead end.
pub fn split_menu(main: &Response, arrow: &Response, add: impl FnOnce(&mut Ui)) {
split_popup(main, arrow).show(|ui| add(ui));
}
/// The shared popup behind [`split_menu`], for callers that adjust its width or close behavior.
pub fn split_popup<'a>(main: &'a Response, arrow: &'a Response) -> egui::Popup<'a> {
// Pointer clicks read Context input, so resolve them before taking the data lock below.
let main_clicked = main.clicked();
let arrow_clicked = arrow.clicked();
let toggle = (main_clicked || arrow_clicked).then_some(egui::SetOpenCommand::Toggle);
let id = split_popup_id(main);
// The popup's geometry and identity are shared, but keyboard entry belongs to the half that
// opened it. Remember that origin while focus moves into the menu (or the Eyedropper runs).
let anchor = main.ctx.data_mut(|data| {
let key = id.with("anchor");
if arrow_clicked {
data.insert_temp(key, arrow.id);
} else if main_clicked {
data.insert_temp(key, main.id);
}
data.get_temp::<egui::Id>(key).unwrap_or(main.id)
});
egui::Popup::menu(main).id(id).anchor(main.rect.union(arrow.rect)).anchor_widget(anchor).open_memory(toggle)
}
/// A dropdown-only split whose menu contains a colour grid, retaining the Eyedropper hand-back.
pub fn split_color_popup<'a>(main: &'a Response, arrow: &'a Response) -> egui::Popup<'a> {
let id = split_popup_id(main);
let mut popup = split_popup(main, arrow).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside);
if crate::eyedropper::result_waits_for(&main.ctx, id) {
popup = popup.open_memory(egui::SetOpenCommand::Bool(true));
}
if popup.is_open() {
crate::eyedropper::note_open_menu(&main.ctx, id);
}
popup
}
/// Derive the popup id from the main button response so a split menu follows that button.
fn split_popup_id(main: &Response) -> egui::Id {
main.id.with("split-menu")
}
/// A menu choice ticked in the icon column (Align to Slide…), its text lined up with the
/// [`small_button`] items around it.
pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response {
@@ -159,7 +248,7 @@ pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response
let font = theme::font(12.0);
let tw = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0;
let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 22.0), if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled, on, label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, enabled, on, label));
let painter = ui.painter_at(rect);
if let Some(c) = bg(ui, &resp, false, &t) {
painter.rect_filled(rect, CornerRadius::same(4), c);
@@ -181,7 +270,7 @@ pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool)
}
icons::paint(&painter, rect.shrink(3.0), icon, t.text, !enabled);
// Named for screen readers (and tests) by its tooltip.
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip));
resp.on_hover_text(tip)
}
@@ -190,11 +279,13 @@ pub fn color_split(ui: &mut Ui, icon: Icon, tip: &str, color: Color32, enabled:
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::hover());
let main = ui.interact(Rect::from_min_size(rect.min, vec2(23.0, 22.0)), resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() });
main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip));
let arrow = ui.interact(
Rect::from_min_max(pos2(rect.min.x + 23.0, rect.min.y), rect.max),
resp.id.with("a"),
if enabled { Sense::click() } else { Sense::hover() },
);
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{tip} options")));
let painter = ui.painter_at(rect);
if let Some(c) = bg(ui, &main, false, &t) {
painter.rect_filled(main.rect, CornerRadius::same(4), c);
@@ -259,6 +350,7 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool,
if enabled { t.text } else { t.text_faint },
);
chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, if current.is_empty() { id } else { current }));
let _ = id;
egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items);
@@ -370,7 +462,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) -
out = Some(None);
}
// "More Colors…": a hex field.
let id = ui.id().with("hex");
let id = ui.scope_id().with("hex");
let mut hex: String = ui.data_mut(|d| d.get_temp::<String>(id).unwrap_or_default());
ui.horizontal(|ui| {
ui.label("More Colors… #");
@@ -422,15 +514,16 @@ pub fn gallery(
clicked
}
/// A labelled number field with up/down steppers. Returns the new value when committed.
pub fn spinner(ui: &mut Ui, id: &str, value: f64, step: f64, suffix: &str, width: f32) -> Option<f64> {
/// A number field with up/down steppers, named by the visible label of its row. Returns the new
/// value when committed.
pub fn spinner(ui: &mut Ui, id: &str, label: &str, value: f64, step: f64, suffix: &str, width: f32) -> Option<f64> {
let t = Tokens::get(ui.ctx());
let key = ui.id().with(id);
let key = ui.scope_id().with(id);
let mut text: String = ui.data_mut(|d| d.get_temp::<String>(key)).unwrap_or_else(|| fmt_num(value, suffix));
let mut out = None;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(width).font(theme::font(12.0)));
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(width).font(theme::font(12.0))).accessible_name(format!("{label} value"));
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(key, text.clone()));
} else {
@@ -444,7 +537,9 @@ pub fn spinner(ui: &mut Ui, id: &str, value: f64, step: f64, suffix: &str, width
}
let (rect, _) = ui.allocate_exact_size(vec2(12.0, 22.0), Sense::hover());
let up = ui.interact(Rect::from_min_max(rect.min, pos2(rect.max.x, rect.center().y)), key.with("up"), Sense::click());
up.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Increase {label}")));
let down = ui.interact(Rect::from_min_max(pos2(rect.min.x, rect.center().y), rect.max), key.with("dn"), Sense::click());
down.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Decrease {label}")));
let p = ui.painter();
let c = rect.center();
p.line_segment([pos2(c.x - 3.0, c.y - 3.0), pos2(c.x, c.y - 6.0)], Stroke::new(1.0, t.text_dim));
@@ -495,7 +590,9 @@ pub fn section(ui: &mut Ui, title: &str, open: bool) -> bool {
/// A button with an optional label and a painted dropdown chevron (the UI font has no ▾).
pub fn drop_button(ui: &mut Ui, label: &str, size: egui::Vec2, enabled: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let r = ui.add_enabled(enabled, egui::Button::new(if label.is_empty() { String::new() } else { format!("{label} ") }).min_size(size));
let r = ui
.add_enabled(enabled, egui::Button::new(if label.is_empty() { String::new() } else { format!("{label} ") }).min_size(size))
.on_hover_text(if label.is_empty() { "Options" } else { label });
let col = if enabled { t.text_dim } else { t.text_faint };
chevron(ui.painter(), egui::pos2(r.rect.max.x - 8.0, r.rect.center().y), 3.0, col);
r
@@ -511,6 +608,155 @@ mod tests {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
/// Office's large split button: the icon and the visible label text run the main command;
/// only the chevron at the end of the label row opens the options menu. The old geometry made
/// the whole label row the arrow target, so clicking "Paste" opened its menu instead of
/// pasting.
#[test]
fn a_large_split_buttons_label_runs_the_command_and_its_chevron_opens_the_options() {
use std::cell::Cell;
use std::rc::Rc;
let main_clicks = Rc::new(Cell::new(0u32));
let arrow_clicks = Rc::new(Cell::new(0u32));
let (main_counter, arrow_counter) = (main_clicks.clone(), arrow_clicks.clone());
let mut harness = egui_kittest::Harness::builder().with_size(vec2(200.0, 100.0)).build_ui(move |ui| {
theme::install_fonts(ui.ctx());
let (main, arrow) = big_button_ex(ui, Icon::Paste, "Paste", true, true, false);
if main.clicked() {
main_counter.set(main_counter.get() + 1);
}
if arrow.is_some_and(|a| a.clicked()) {
arrow_counter.set(arrow_counter.get() + 1);
}
});
harness.run();
let main_rect = harness.get_by_role_and_label(Role::Button, "Paste").rect();
let arrow_rect = harness.get_by_role_and_label(Role::Button, "Paste options").rect();
let label = egui::pos2(main_rect.center().x, main_rect.min.y + 45.0);
assert!(main_rect.contains(label) && !arrow_rect.contains(label), "the visible label is outside the chevron strip");
/// Nested large-split-button test helper: move and press/release the primary pointer at a
/// position.
fn click(harness: &mut egui_kittest::Harness<'_, ()>, pos: egui::Pos2) {
harness.input_mut().events.push(egui::Event::PointerMoved(pos));
harness.run();
for pressed in [true, false] {
harness.input_mut().events.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
});
harness.run();
}
}
click(&mut harness, label);
assert_eq!(main_clicks.get(), 1, "clicking the label runs the main command");
assert_eq!(arrow_clicks.get(), 0, "clicking the label does not open the menu");
click(&mut harness, arrow_rect.center());
assert_eq!(main_clicks.get(), 1, "clicking the chevron does not run the main command");
assert_eq!(arrow_clicks.get(), 1, "clicking the chevron opens the options");
}
/// A two-line split label reserves the arrow strip outside *all* visible text. The click
/// lands on the last line's widest glyph pixels (the old overlap) and runs the main command;
/// only the named arrow opens the menu.
#[test]
fn a_two_line_split_labels_last_text_pixels_run_the_command() {
use std::cell::Cell;
use std::rc::Rc;
let main_clicks = Rc::new(Cell::new(0u32));
let arrow_clicks = Rc::new(Cell::new(0u32));
let last_line = Rc::new(RefCell::new(None));
let (main_counter, arrow_counter) = (main_clicks.clone(), arrow_clicks.clone());
let (last_line_out, last_line_cell) = (last_line.clone(), last_line.clone());
let mut harness = egui_kittest::Harness::builder().with_size(vec2(220.0, 100.0)).build_ui(move |ui| {
theme::install_fonts(ui.ctx());
let label = "Send\nBackward";
let (main, arrow) = big_button_ex(ui, Icon::SendBackward, label, true, true, false);
let font = theme::font(11.0);
let col = Tokens::get(ui.ctx()).text;
let lines = wrap_label(label);
let rect = main.rect;
let label_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - SPLIT_ARROW_W, rect.max.y));
let last = lines.last().copied().unwrap_or_default();
let last_w = ui.fonts_mut(|f| f.layout_no_wrap(last.to_string(), font.clone(), col).size().x);
let y = rect.min.y + 39.0 + 12.5 * lines.len().saturating_sub(1) as f32;
let x = label_rect.center().x - last_w / 2.0;
*last_line_cell.borrow_mut() = Some(Rect::from_min_size(pos2(x, y), vec2(last_w, 12.0)));
if main.clicked() {
main_counter.set(main_counter.get() + 1);
}
if arrow.is_some_and(|a| a.clicked()) {
arrow_counter.set(arrow_counter.get() + 1);
}
});
harness.run();
let arrow_rect = harness.get_by_role_and_label(Role::Button, "Send Backward options").rect();
let text = last_line_out.borrow().expect("last line laid out");
assert!(text.max.x < arrow_rect.min.x, "the reserved strip is outside all visible text: {text:?} vs {arrow_rect:?}");
let label_pixels = pos2(text.max.x - 1.0, text.center().y);
assert!(!arrow_rect.contains(label_pixels));
/// Nested two-line-split-button test helper: move and press/release the primary pointer at a
/// position.
fn click(harness: &mut egui_kittest::Harness<'_, ()>, pos: egui::Pos2) {
harness.input_mut().events.push(egui::Event::PointerMoved(pos));
harness.run();
for pressed in [true, false] {
harness.input_mut().events.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
});
harness.run();
}
}
click(&mut harness, label_pixels);
assert_eq!(main_clicks.get(), 1, "the last line's real text pixels run the main command");
assert_eq!(arrow_clicks.get(), 0, "the last line's text does not open the menu");
click(&mut harness, arrow_rect.center());
assert_eq!(main_clicks.get(), 1, "the arrow does not run the main command");
assert_eq!(arrow_clicks.get(), 1, "the arrow opens the options");
}
/// Small split arrows carry their own command name; bullets and numbering must not both be
/// announced as a generic "More options" target.
#[test]
fn small_split_arrows_are_named_for_their_own_commands() {
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 100.0)).build_ui(|ui| {
theme::install_fonts(ui.ctx());
let _ = small_button_named(ui, Icon::Bullets, "", "Bullets", true, false, true);
let _ = small_button_named(ui, Icon::Numbering, "", "Numbering", true, false, true);
});
harness.run();
for name in ["Bullets", "Bullets options", "Numbering", "Numbering options"] {
assert!(harness.get_by_role_and_label(Role::Button, name).rect().width() > 0.0, "{name} is a named target");
}
assert!(harness.query_by_role_and_label(Role::Button, "More options").is_none(), "no shared arrow name remains");
}
/// The custom ribbon dropdown is a real combo box in the accessibility tree, named by its
/// current value (0.37's strict unnamed-input check would otherwise reject it).
#[test]
fn the_dropdown_has_a_combo_box_role_and_a_name() {
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| {
theme::install_fonts(ui.ctx());
dropdown(ui, "font-size", "18 pt", 120.0, true, |ui| {
ui.label("12 pt");
ui.label("18 pt");
});
});
harness.run();
let combo = harness.get_by_role_and_label(Role::ComboBox, "18 pt");
assert!(combo.rect().width() > 100.0);
}
/// #32: clicking the "More Colors" hex field closed the colour menu before anything could be
/// typed. It stays open now; Enter picks the colour and closes it.
#[test]
@@ -561,7 +807,7 @@ mod tests {
assert!(harness.query_by_label("Eyedropper").is_none(), "choosing it closes the menu");
assert!(picked.borrow().is_none(), "nothing is picked yet");
// The app starts the Eyedropper from this request; here the colour is taken at once.
assert!(harness.ctx.data_mut(|d| d.remove_temp::<bool>(egui::Id::new("deckcraft.eyedropper.request"))).unwrap_or(false));
assert!(harness.ctx.data_mut(|d| d.remove_temp::<bool>(egui::Id::unique("deckcraft.eyedropper.request"))).unwrap_or(false));
let menu = crate::eyedropper::open_menu(&harness.ctx).expect("the open menu noted its button");
crate::eyedropper::store_result(&harness.ctx, menu, "00FF00");
harness.run();
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,6 @@
# Localization parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
@@ -25,7 +25,7 @@ support partly there), **55–90 h** to parity plus native-speaker review.
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full |
|---|---|---|---|---|---|---|---|---|
| English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 |
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) |
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | native candidate optionally embeds LXGW WenKai Lite via craft-fonts, with existing per-character fallback; other builds use system CJK fonts (Noto CJK remains excluded); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) |
| Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 |
| Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 |
| Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) |
@@ -58,6 +58,7 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Local 0.37 candidate adds an opt-in native LXGW WenKai Lite glyph fallback and CJK interface-test coverage. This records local implementation only: managed tests, formatting, clippy, wasm-size and native GUI qualification remain pending. Browser font budget and uniform Chinese typography are unchanged. |
| 2026-10-14 | minor | IME composition in a table cell is underlined in place; the candidate window sits on that cell's caret |
| 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) |
| 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle |
+1 -1
View File
@@ -50,7 +50,7 @@ warn() {
# Copy licence and readme files that exist into a package directory.
copy_docs() {
local dest="$1" f
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE; do
if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi
done
copy_font_licences "$dest"
@@ -47,6 +47,7 @@ modules:
build-commands:
- cargo --offline build --release --locked -p deckcraft
- install -Dm755 target/release/deckcraft /app/bin/deckcraft
- install -Dm644 NOTICE /app/share/doc/deckcraft/NOTICE
- install -Dm644 packaging/linux/ai.storyteller.deckcraft.desktop /app/share/applications/ai.storyteller.deckcraft.desktop
- install -Dm644 packaging/linux/ai.storyteller.deckcraft.mime.xml /app/share/mime/packages/ai.storyteller.deckcraft.xml
- mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/
+12
View File
@@ -11,6 +11,9 @@
UpgradeCode is fixed for DeckCraft forever (playbook §6); never change it.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<?ifndef HasCraftFontLicences ?>
<?define HasCraftFontLicences=0 ?>
<?endif ?>
<Package
Name="DeckCraft"
Manufacturer="Learning Machines LLC"
@@ -38,13 +41,22 @@
<StandardDirectory Id="ProgramMenuFolder" />
<Feature Id="Main" Title="DeckCraft" Level="1">
<?if $(var.HasCraftFontLicences) = 1 ?>
<ComponentGroupRef Id="CraftFontLicences" />
<?endif ?>
<ComponentGroupRef Id="DeckcraftFiles" />
</Feature>
</Package>
<Fragment>
<?if $(var.HasCraftFontLicences) = 1 ?>
<ComponentGroup Id="CraftFontLicences" Directory="INSTALLFOLDER">
<Files Include="$(var.DocsDir)\**" />
</ComponentGroup>
<?endif ?>
<ComponentGroup Id="DeckcraftFiles" Directory="INSTALLFOLDER">
<Component Id="DeckcraftApp" Guid="D87EA444-41A4-4C19-922E-66D1A29461FF">
<File Id="DeckcraftNotice" Source="$(var.BinDir)\NOTICE" KeyPath="no" />
<File Id="DeckcraftExe" Source="$(var.BinDir)\deckcraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="DeckCraft"
Description="DeckCraft presentations and slide shows" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
+17 -1
View File
@@ -86,15 +86,31 @@ foreach ($check in @(@('deckcraft.exe', 2), @('deckcraft-cli.exe', 3))) {
$Stage = Join-Path $TargetDir "windows-package\$Arch"
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
# Dependency font notices are required even without optional craft-fonts.
Copy-Item -LiteralPath (Join-Path $Root 'NOTICE') -Destination $Stage
Copy-Item (Join-Path $Bin 'deckcraft.exe'), (Join-Path $Bin 'deckcraft-cli.exe') $Stage
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'deckcraft.exe') (Join-Path $Stage 'deckcraft-cli.exe')
# ---- font notices for the MSI ------------------------------------------------------------------
# The portable ZIP already carries these notices; stage the complete files before MSI creation too.
$Docs = Join-Path $Stage 'font-licences'
New-Item -ItemType Directory -Force -Path $Docs | Out-Null
$HasCraftFontLicences = 0
if ($env:CRAFT_FONTS_DIR) {
foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) {
Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt")
$HasCraftFontLicences = 1
}
}
# ---- end font notices for the MSI --------------------------------------------------------------
# ---- MSI ---------------------------------------------------------------------------------------
$Msi = Join-Path $Dist "deckcraft-$Version-windows-$Arch.msi"
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot 'deckcraft.wxs') -arch $Arch `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\deckcraft.ico')" `
-d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" `
-o $Msi
}
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
@@ -106,7 +122,7 @@ $Portable = Join-Path $TargetDir "windows-package\deckcraft-$Version-windows-$Ar
Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Portable | Out-Null
Copy-Item (Join-Path $Stage '*.exe') $Portable
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') {
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $Portable }
}