mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
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.
This commit is contained in:
@@ -17,12 +17,45 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# The migration's font pin gate stays a local preflight. A reusable-workflow job cannot run
|
||||
# custom steps or export CRAFT_FONTS_REQUIRED into gate.yml, so the check is its own job and
|
||||
# the shared gate waits for it. Keep this pin identical to the `craft-fonts-ref` below,
|
||||
# freebsd.yml and release.yml.
|
||||
font-pin:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
CRAFT_FONTS_REF: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
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: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
packages: >-
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev
|
||||
libgl1-mesa-dev
|
||||
|
||||
@@ -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: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -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: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
+18
-3
@@ -41,7 +41,7 @@ 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 |
|
||||
| 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, 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 |
|
||||
|
||||
## Embedded at build time from craft-fonts (`CRAFT_FONTS_DIR`)
|
||||
@@ -58,9 +58,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 +80,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
+193
-228
@@ -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"
|
||||
@@ -816,7 +788,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[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,18 +2391,18 @@ 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]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
@@ -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=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181"
|
||||
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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_system_font_provider"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
+22
-6
@@ -60,16 +60,17 @@ vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
i_overlay = "9"
|
||||
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 }
|
||||
|
||||
@@ -103,4 +104,19 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,10 +75,13 @@ struct WebShell {
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
// Capture the document and slide now. The bytes arrive later, after the user may
|
||||
// have switched slides.
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
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 +100,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) {
|
||||
|
||||
@@ -42,8 +42,8 @@ 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);
|
||||
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);
|
||||
|
||||
@@ -49,7 +49,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
|
||||
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
|
||||
} {
|
||||
i += 2;
|
||||
while cs.get(i).is_some_and(|d| d.is_ascii_digit()) {
|
||||
while cs.get(i).is_some_and(char::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -2222,18 +2222,16 @@ fn neg_deinterleave(diff: i32, r: i32, max: i32) -> i32 {
|
||||
if diff <= 2 * r {
|
||||
if diff & 1 == 1 {
|
||||
return r + ((diff + 1) >> 1);
|
||||
} else {
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
diff
|
||||
} else {
|
||||
if diff <= 2 * (max - r - 1) {
|
||||
if diff & 1 == 1 {
|
||||
return r + ((diff + 1) >> 1);
|
||||
} else {
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
max - (diff + 1)
|
||||
}
|
||||
|
||||
@@ -1761,6 +1761,39 @@ fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option<Option<u32>>, bin_size: Option<Option<f64>>) {
|
||||
if let Some(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) {
|
||||
if let Some(t) = subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
|
||||
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
|
||||
@@ -1772,33 +1805,8 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let subtotals: Option<Vec<usize>> =
|
||||
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
|
||||
with_chart(s, p, "chart.options", |c| {
|
||||
if let Some(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
if let Some(t) = &subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
apply_bin_options(c, bin_count, bin_size);
|
||||
apply_subtotals_and_bars(c, p, subtotals.as_deref());
|
||||
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
|
||||
c.raw = None;
|
||||
}
|
||||
|
||||
@@ -539,9 +539,9 @@ fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if doc.sections.is_empty() {
|
||||
// The slides before `at` go into a "Default Section".
|
||||
if at > 0 {
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
}
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
} else {
|
||||
let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) };
|
||||
let si = doc.section_of(start).unwrap_or(0);
|
||||
|
||||
@@ -10,7 +10,7 @@ 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};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -66,6 +66,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 +135,235 @@ 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))
|
||||
}
|
||||
|
||||
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 walk is what bounds the surrounding query: it never looks further than `limit`, so a
|
||||
/// megabyte body costs no more than a short one.
|
||||
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 never visits the rest of the body.
|
||||
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))?;
|
||||
@@ -841,7 +1094,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,
|
||||
@@ -1060,6 +1315,89 @@ 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))
|
||||
}
|
||||
|
||||
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();
|
||||
// Where the deletion before `start` begins decides how the selection moves: within the start
|
||||
// paragraph its characters shift; from an earlier paragraph those paragraphs merge, so later
|
||||
// paragraph numbers shift too.
|
||||
let (from_para, from_char) = from;
|
||||
let remap = move |pos: (usize, usize)| -> (usize, usize) {
|
||||
if before_chars == 0 || pos < start {
|
||||
return pos;
|
||||
}
|
||||
if from_para == 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_para + (pos.0 - start.0), if pos.0 == start.0 { from_char + (pos.1 - start.1) } else { pos.1 })
|
||||
}
|
||||
};
|
||||
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(t.anchor);
|
||||
t.caret = remap(t.caret);
|
||||
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_para == 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 });
|
||||
}
|
||||
Ok(json!({"deleted": deleted}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
||||
}
|
||||
@@ -1475,6 +1813,207 @@ mod rtl_promote_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ime_surrounding_tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
|
||||
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "unknown panic".into())
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ pub fn stroke_width(icon: &Icon, w: f64, h: f64) -> f64 {
|
||||
}
|
||||
|
||||
fn parsed() -> &'static HashMap<&'static str, BezPath> {
|
||||
static CACHE: OnceLock<HashMap<&'static str, BezPath>> = OnceLock::new();
|
||||
static CACHE: OnceLock<HashMap<&str, BezPath>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
for icon in ICONS {
|
||||
|
||||
@@ -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);
|
||||
@@ -247,6 +247,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).
|
||||
|
||||
@@ -482,6 +482,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 {
|
||||
@@ -513,6 +515,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`]);
|
||||
@@ -590,7 +593,7 @@ impl Session {
|
||||
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());
|
||||
@@ -660,6 +663,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 Some(st) = self.active_mut() else { return };
|
||||
let Some(c) = st.composition.take() else { return };
|
||||
@@ -668,6 +674,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()?;
|
||||
@@ -683,7 +710,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(())
|
||||
}
|
||||
|
||||
@@ -152,13 +152,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return out };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") {
|
||||
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("deckcraft") {
|
||||
continue;
|
||||
}
|
||||
if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
|
||||
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
|
||||
.and_then(|t| serde_json::from_slice::<Value>(&t).ok())
|
||||
|
||||
@@ -3233,6 +3233,32 @@ 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;
|
||||
assert!(metric_pair || option_env!("CRAFT_FONTS_REQUIRED").is_none(), "required BIZ Regular/Bold pair is absent");
|
||||
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();
|
||||
@@ -3257,7 +3283,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.
|
||||
|
||||
@@ -451,7 +451,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 => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
+165
-17
@@ -1,27 +1,39 @@
|
||||
//! 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
|
||||
//! `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 and BIZ UDPGothic Regular, to stay within the web size budget. 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"];
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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,22 +43,54 @@ fn main() {
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result<String, 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(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> 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, &latin, wasm)?);
|
||||
}
|
||||
out.push_str(&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) => out.push_str(&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()));
|
||||
}
|
||||
}
|
||||
out.push_str(&manifest(dir, &dir.join("fonts/manifest.txt"))?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result<String, String> {
|
||||
fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool) -> Result<String, 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();
|
||||
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();
|
||||
@@ -54,7 +98,7 @@ fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result<String,
|
||||
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")) {
|
||||
@@ -72,3 +116,107 @@ fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result<String,
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct InputFixture(PathBuf);
|
||||
|
||||
impl InputFixture {
|
||||
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",
|
||||
)
|
||||
.unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn optional(&self, text: &str) {
|
||||
std::fs::write(self.0.join("fonts/simplified-chinese-manifest.txt"), text).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[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(), "");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_opt_in_does_not_change_wasm_embedded_bytes() {
|
||||
let fixture = InputFixture::new();
|
||||
let before = input_fonts(Some(&fixture.0), true, true).unwrap();
|
||||
fixture.optional("LXGW WenKai Lite | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid");
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_native_input_requires_the_chinese_manifest() {
|
||||
let fixture = InputFixture::new();
|
||||
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());
|
||||
}
|
||||
|
||||
#[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();
|
||||
let mut rows = String::new();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+28
-27
@@ -161,12 +161,12 @@ pub struct FaceRef(&'static FontFace);
|
||||
impl FaceRef {
|
||||
/// The handle for a loaded face (memoised per face).
|
||||
pub fn of(face: &Arc<FontFace>) -> FaceRef {
|
||||
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
|
||||
static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
|
||||
let id = face.id as usize;
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(std::sync::PoisonError::into_inner).get(id) {
|
||||
return FaceRef(f);
|
||||
}
|
||||
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut w = LEAKED.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if w.len() <= id {
|
||||
w.resize(id + 1, None);
|
||||
}
|
||||
@@ -743,7 +743,7 @@ pub fn base_style(style: &str) -> &str {
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// Weight implied by a style name.
|
||||
@@ -807,7 +807,7 @@ impl FontDb {
|
||||
pub fn set_system_fallback(&self, on: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled = on;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = on;
|
||||
@@ -822,14 +822,14 @@ impl FontDb {
|
||||
}
|
||||
|
||||
pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
self.faces.read().unwrap_or_else(|e| e.into_inner())
|
||||
self.faces.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The system font catalog, scanned first if it hasn't been yet.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
|
||||
self.ensure_catalog();
|
||||
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
||||
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.
|
||||
@@ -885,7 +885,7 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut added = 0;
|
||||
for f in new {
|
||||
// Checked again under the lock: another thread may have added the same face.
|
||||
@@ -923,7 +923,7 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_system_fonts(&self) -> usize {
|
||||
// Families load again from the new catalog when next looked up.
|
||||
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
|
||||
// The first scan, or a rescan once it is done (never both at once).
|
||||
let mut first = None;
|
||||
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
|
||||
@@ -959,7 +959,7 @@ impl FontDb {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
@@ -975,7 +975,7 @@ impl FontDb {
|
||||
}
|
||||
let n = found.len();
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
|
||||
*self.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner) = found;
|
||||
n
|
||||
}
|
||||
|
||||
@@ -983,7 +983,7 @@ impl FontDb {
|
||||
/// it is loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_cataloged(&self, family: &str) -> bool {
|
||||
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
self.load_family(family, &loading)
|
||||
}
|
||||
|
||||
@@ -1047,7 +1047,7 @@ impl FontDb {
|
||||
// them rather than finding none and falling back to another family.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let key = family.to_lowercase();
|
||||
if !loaded.contains(&key) {
|
||||
self.load_cataloged(family);
|
||||
@@ -1112,7 +1112,7 @@ impl FontDb {
|
||||
}
|
||||
let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
|
||||
let f = Arc::new(f);
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
|
||||
self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(f.clone());
|
||||
Some(f)
|
||||
}
|
||||
|
||||
@@ -1214,20 +1214,20 @@ impl FontDb {
|
||||
}
|
||||
let seen = self.read_faces().len();
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !sys.enabled || sys.misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f));
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Another thread may have loaded a face for `c` (faces are only ever added), or found
|
||||
// there is none, while this one waited.
|
||||
if self.read_faces().len() != seen && covered(self) {
|
||||
return true;
|
||||
}
|
||||
if self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.contains(&c) {
|
||||
if self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
for fam in SYSTEM_FALLBACKS {
|
||||
@@ -1238,7 +1238,8 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
|
||||
let mut paths: Vec<std::path::PathBuf> =
|
||||
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
@@ -1253,7 +1254,7 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
if remember_miss {
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.insert(c);
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -1326,10 +1327,10 @@ impl FontDb {
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
|
||||
if !self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled {
|
||||
return false;
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
@@ -1347,7 +1348,7 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1359,12 +1360,12 @@ impl FontDb {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !sys.enabled {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
@@ -1382,14 +1383,14 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
@@ -1399,7 +1400,7 @@ impl FontDb {
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
|
||||
}
|
||||
let p = Arc::new(pen.0);
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Exercise the build-input parser without changing process environment variables.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../build.rs"]
|
||||
mod build_script;
|
||||
+26
-26
@@ -1070,33 +1070,33 @@ mod tests {
|
||||
assert_eq!(md_list_item(not), None, "{not:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #814: slides_to_outline includes table cell text, which it used to skip.
|
||||
#[test]
|
||||
fn outline_includes_table_text() {
|
||||
use deckcraft_model::table::{Cell, Row, Table};
|
||||
use deckcraft_model::{Shape, ShapeKind, Xfrm};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
|
||||
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
|
||||
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
|
||||
body.text = Some(TextBody::from_text("Text box body"));
|
||||
s.shapes.push(body);
|
||||
/// #814: slides_to_outline includes table cell text, which it used to skip.
|
||||
#[test]
|
||||
fn outline_includes_table_text() {
|
||||
use deckcraft_model::table::{Cell, Row, Table};
|
||||
use deckcraft_model::{Shape, ShapeKind, Xfrm};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
|
||||
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
|
||||
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
|
||||
body.text = Some(TextBody::from_text("Text box body"));
|
||||
s.shapes.push(body);
|
||||
|
||||
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
|
||||
for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] {
|
||||
let cells: Vec<Cell> = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect();
|
||||
table.rows.push(Row { height: 30.0, cells });
|
||||
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
|
||||
for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] {
|
||||
let cells: Vec<Cell> = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect();
|
||||
table.rows.push(Row { height: 30.0, cells });
|
||||
}
|
||||
let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() };
|
||||
tbl.kind = ShapeKind::Table(table);
|
||||
s.shapes.push(tbl);
|
||||
p.slides = vec![std::sync::Arc::new(s)];
|
||||
|
||||
let out = slides_to_outline(&p);
|
||||
assert!(out.contains("Text box body"), "{out}");
|
||||
assert!(out.contains("Cell A1\tCell B1"), "{out}");
|
||||
assert!(out.contains("Cell A2\tCell B2"), "{out}");
|
||||
}
|
||||
let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() };
|
||||
tbl.kind = ShapeKind::Table(table);
|
||||
s.shapes.push(tbl);
|
||||
p.slides = vec![std::sync::Arc::new(s)];
|
||||
|
||||
let out = slides_to_outline(&p);
|
||||
assert!(out.contains("Text box body"), "{out}");
|
||||
assert!(out.contains("Cell A1\tCell B1"), "{out}");
|
||||
assert!(out.contains("Cell A2\tCell B2"), "{out}");
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ impl Player {
|
||||
}
|
||||
|
||||
fn with<R>(&self, f: impl FnOnce(&mut Mixer) -> R) -> R {
|
||||
let mut g = self.mixer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = self.mixer.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
f(&mut g)
|
||||
}
|
||||
|
||||
|
||||
@@ -1187,7 +1187,7 @@ fn image(r: Bytes) -> Option<ImageData> {
|
||||
fn metafile(r: Bytes) -> Option<Picture> {
|
||||
let size = r.u32(12)? as usize;
|
||||
let data = r.slice(16, size).or_else(|| r.tail(16).map(|t| t.0))?;
|
||||
let depth = NESTING.with(|n| n.get());
|
||||
let depth = NESTING.with(std::cell::Cell::get);
|
||||
if depth >= MAX_NESTING {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1716,7 +1716,7 @@ fn plus_pic(recs: Vec<Vec<u8>>) -> Picture {
|
||||
}
|
||||
|
||||
fn u16s(s: &str) -> Vec<u8> {
|
||||
s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()
|
||||
s.encode_utf16().flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
/// A 2 x 2 32bpp ARGB bitmap image object: red, green / blue, white.
|
||||
|
||||
+1499
-1469
File diff suppressed because it is too large
Load Diff
@@ -1066,13 +1066,13 @@ impl Presentation {
|
||||
self.slides.iter().position(|s| s.id == id)
|
||||
}
|
||||
pub fn slide(&self, id: SlideId) -> Option<&Slide> {
|
||||
self.slides.iter().find(|s| s.id == id).map(|s| s.as_ref())
|
||||
self.slides.iter().find(|s| s.id == id).map(AsRef::as_ref)
|
||||
}
|
||||
pub fn slide_mut(&mut self, id: SlideId) -> Option<&mut Slide> {
|
||||
self.slides.iter_mut().find(|s| s.id == id).map(Arc::make_mut)
|
||||
}
|
||||
pub fn slide_at(&self, i: usize) -> Option<&Slide> {
|
||||
self.slides.get(i).map(|s| s.as_ref())
|
||||
self.slides.get(i).map(AsRef::as_ref)
|
||||
}
|
||||
/// Master and layout for a layout id.
|
||||
pub fn layout(&self, id: LayoutId) -> Option<(&Master, &Layout)> {
|
||||
@@ -1086,7 +1086,7 @@ impl Presentation {
|
||||
self.masters.first().map(|m| (m.as_ref(), m.layouts.first()))
|
||||
}
|
||||
pub fn first_master(&self) -> Option<&Master> {
|
||||
self.masters.first().map(|m| m.as_ref())
|
||||
self.masters.first().map(AsRef::as_ref)
|
||||
}
|
||||
pub fn media(&self, id: MediaId) -> Option<&MediaItem> {
|
||||
self.media.iter().find(|m| m.id == id)
|
||||
|
||||
@@ -275,7 +275,7 @@ pub enum Dash {
|
||||
}
|
||||
|
||||
impl Dash {
|
||||
pub const PRESETS: [&'static str; 11] =
|
||||
pub const PRESETS: [&str; 11] =
|
||||
["solid", "sysDot", "sysDash", "dash", "dashDot", "lgDash", "lgDashDot", "lgDashDotDot", "dot", "sysDashDot", "sysDashDotDot"];
|
||||
/// Dash/space lengths in multiples of the line width.
|
||||
pub fn pattern(&self) -> Vec<f64> {
|
||||
|
||||
@@ -530,7 +530,7 @@ impl Paragraph {
|
||||
true
|
||||
}
|
||||
pub fn text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.content()).collect()
|
||||
self.runs.iter().map(Run::content).collect()
|
||||
}
|
||||
pub fn char_len(&self) -> usize {
|
||||
self.runs.iter().map(Run::char_len).sum()
|
||||
@@ -576,7 +576,7 @@ pub struct ListStyle {
|
||||
|
||||
impl ListStyle {
|
||||
pub fn level(&self, l: u8) -> Option<&LevelStyle> {
|
||||
self.levels.get(l as usize).and_then(|x| x.as_ref())
|
||||
self.levels.get(l as usize).and_then(Option::as_ref)
|
||||
}
|
||||
pub fn set(&mut self, l: u8, s: LevelStyle) {
|
||||
let i = (l as usize).min(8);
|
||||
|
||||
@@ -57,22 +57,7 @@ impl Trendline {
|
||||
let yhat: Vec<f64> = xs.iter().map(|x| poly_y(&coeff, *x)).collect();
|
||||
(Model::Poly(coeff), ys, yhat)
|
||||
}
|
||||
TrendlineType::Exp => {
|
||||
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(_, y)| *y > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (xs, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip();
|
||||
let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln());
|
||||
let (ln_c, b) = linreg(&xs, &ln_y, fixed)?;
|
||||
let c = ln_c.exp();
|
||||
if !c.is_finite() || c <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = xs.iter().map(|x| c * (b * x).exp()).collect();
|
||||
(Model::Exp { c, b }, ys, yhat)
|
||||
}
|
||||
TrendlineType::Exp => fit_exp(&pairs, intercept)?,
|
||||
TrendlineType::Log => {
|
||||
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, _)| *x > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
@@ -83,43 +68,10 @@ impl Trendline {
|
||||
let yhat: Vec<f64> = ln_x.iter().map(|u| b + c * u).collect();
|
||||
(Model::Log { b, c }, ys, yhat)
|
||||
}
|
||||
TrendlineType::Power => {
|
||||
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (ln_x, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip();
|
||||
let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln());
|
||||
let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?;
|
||||
let c = ln_c.exp();
|
||||
if !c.is_finite() || c <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = pairs.iter().map(|(x, _)| c * x.powf(b)).collect();
|
||||
(Model::Power { c, b }, ys, yhat)
|
||||
}
|
||||
TrendlineType::Power => fit_power(&pairs, intercept)?,
|
||||
TrendlineType::MovingAvg => {
|
||||
let period = self.period.unwrap_or(2).clamp(2, 255) as usize;
|
||||
if pairs.len() <= period {
|
||||
return None;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
let mut yhat = Vec::new();
|
||||
for i in period - 1..pairs.len() {
|
||||
let window = &pairs[i + 1 - period..=i];
|
||||
let avg = window.iter().map(|(_, y)| y).sum::<f64>() / period as f64;
|
||||
if avg.is_finite() {
|
||||
pts.push((pairs[i].0, avg));
|
||||
ys.push(pairs[i].1);
|
||||
yhat.push(avg);
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
(Model::Moving(pts), ys, yhat)
|
||||
fit_moving_avg(&pairs, period)?
|
||||
}
|
||||
};
|
||||
if yhat.iter().any(|v| !v.is_finite()) {
|
||||
@@ -325,6 +277,89 @@ fn poly_y(coeff: &[f64], x: f64) -> f64 {
|
||||
acc
|
||||
}
|
||||
|
||||
fn fit_exp(pairs: &[(f64, f64)], intercept: Option<f64>) -> Option<(Model, Vec<f64>, Vec<f64>)> {
|
||||
let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(_, y)| *y > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (xs, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip();
|
||||
let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln);
|
||||
let (ln_c, b) = linreg(&xs, &ln_y, fixed)?;
|
||||
let c = ln_c.exp();
|
||||
if !c.is_finite() || c <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = xs.iter().map(|x| c * (b * x).exp()).collect();
|
||||
Some((Model::Exp { c, b }, ys, yhat))
|
||||
}
|
||||
|
||||
fn fit_power(pairs: &[(f64, f64)], intercept: Option<f64>) -> Option<(Model, Vec<f64>, Vec<f64>)> {
|
||||
let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (ln_x, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip();
|
||||
let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln);
|
||||
let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?;
|
||||
let c = ln_c.exp();
|
||||
if !c.is_finite() || c <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = pairs.iter().map(|(x, _)| c * x.powf(b)).collect();
|
||||
Some((Model::Power { c, b }, ys, yhat))
|
||||
}
|
||||
|
||||
fn fit_moving_avg(pairs: &[(f64, f64)], period: usize) -> Option<(Model, Vec<f64>, Vec<f64>)> {
|
||||
if pairs.len() <= period {
|
||||
return None;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
let mut yhat = Vec::new();
|
||||
for i in period - 1..pairs.len() {
|
||||
let window = &pairs[i + 1 - period..=i];
|
||||
let avg = window.iter().map(|(_, y)| y).sum::<f64>() / period as f64;
|
||||
if avg.is_finite() {
|
||||
pts.push((pairs[i].0, avg));
|
||||
ys.push(pairs[i].1);
|
||||
yhat.push(avg);
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some((Model::Moving(pts), ys, yhat))
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option<Vec<f64>> {
|
||||
let m = order;
|
||||
let mut ata = vec![vec![0.0; m]; m];
|
||||
let mut atb = vec![0.0; m];
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
let dy = y - b0;
|
||||
for i in 0..m {
|
||||
let xi = x.powi((i + 1) as i32);
|
||||
atb[i] += dy * xi;
|
||||
for j in i..m {
|
||||
ata[i][j] += xi * x.powi((j + 1) as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..m {
|
||||
for j in 0..i {
|
||||
ata[i][j] = ata[j][i];
|
||||
}
|
||||
}
|
||||
let solved = solve(ata, atb)?;
|
||||
let mut coeff = Vec::with_capacity(order + 1);
|
||||
coeff.push(b0);
|
||||
coeff.extend(solved);
|
||||
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
||||
}
|
||||
|
||||
/// Polynomial through the normal equations. `x` is centred when the intercept is free, then
|
||||
/// expanded back to powers of `x`, so order 6 on category numbers stays solvable.
|
||||
#[allow(clippy::needless_range_loop)] // the index is the power in the normal equations
|
||||
@@ -334,29 +369,7 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
|
||||
return None;
|
||||
}
|
||||
if let Some(b0) = intercept {
|
||||
let m = order;
|
||||
let mut ata = vec![vec![0.0; m]; m];
|
||||
let mut atb = vec![0.0; m];
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
let dy = y - b0;
|
||||
for i in 0..m {
|
||||
let xi = x.powi((i + 1) as i32);
|
||||
atb[i] += dy * xi;
|
||||
for j in i..m {
|
||||
ata[i][j] += xi * x.powi((j + 1) as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..m {
|
||||
for j in 0..i {
|
||||
ata[i][j] = ata[j][i];
|
||||
}
|
||||
}
|
||||
let solved = solve(ata, atb)?;
|
||||
let mut coeff = Vec::with_capacity(order + 1);
|
||||
coeff.push(b0);
|
||||
coeff.extend(solved);
|
||||
return coeff.iter().all(|v| v.is_finite()).then_some(coeff);
|
||||
return polyfit_with_intercept(xs, ys, order, b0);
|
||||
}
|
||||
let xm = xs.iter().sum::<f64>() / n as f64;
|
||||
let us: Vec<f64> = xs.iter().map(|x| x - xm).collect();
|
||||
|
||||
@@ -71,7 +71,7 @@ fn exp_rotation1(x: &mut [f32], len: usize, stride: usize, c: f32, s: f32) {
|
||||
x[i + stride] = c * x2 + s * x1;
|
||||
x[i] = c * x1 + ms * x2;
|
||||
}
|
||||
if len >= 2 * stride + 1 {
|
||||
if len > 2 * stride {
|
||||
for i in (0..=len - 2 * stride - 1).rev() {
|
||||
let x1 = x[i];
|
||||
let x2 = x[i + stride];
|
||||
|
||||
@@ -578,7 +578,7 @@ fn tf_decode(start: usize, end: usize, is_transient: bool, tf_res: &mut [i32; NB
|
||||
let mut budget = dec.storage() as i32 * 8;
|
||||
let mut tell = dec.tell();
|
||||
let mut logp: u32 = if is_transient { 2 } else { 4 };
|
||||
let tf_select_rsv = lm > 0 && tell + logp as i32 + 1 <= budget;
|
||||
let tf_select_rsv = lm > 0 && tell + (logp as i32) < budget;
|
||||
budget -= tf_select_rsv as i32;
|
||||
let mut tf_changed = 0;
|
||||
let mut curr = 0;
|
||||
|
||||
@@ -217,7 +217,7 @@ impl StreamDecoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
let data_len = data.map_or(0, |d| d.len());
|
||||
let data_len = data.map_or(0, <[_]>::len);
|
||||
let mut dec_storage;
|
||||
let mut dec: Option<&mut RangeDecoder> = match data {
|
||||
Some(d) => {
|
||||
|
||||
@@ -29,4 +29,4 @@ wasm-bindgen-test = "0.3"
|
||||
# slide renders (the same bytes, in parallel). The root patch selects the same pinned
|
||||
# backend as craft-pdf; this dependency only enables its native compression feature.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
krilla = { version = "=0.8.2", features = ["rayon"] }
|
||||
krilla = { version = "=0.8.2", default-features = false, features = ["rayon"] }
|
||||
|
||||
+29
-21
@@ -131,6 +131,33 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
write::guard(|| export_inner(pres, opts))
|
||||
}
|
||||
|
||||
fn plan_pages(pres: &Presentation, opts: &PdfOptions, slides: &[usize], sw: f64, sh: f64) -> Vec<Vec<(usize, Rect)>> {
|
||||
match opts.layout {
|
||||
PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(),
|
||||
PageLayout::Notes => {
|
||||
let b = notes_frames(pres, opts.paper).0;
|
||||
slides.iter().map(|&i| vec![(i, b)]).collect()
|
||||
}
|
||||
PageLayout::Handouts { per_page } => {
|
||||
let boxes = handout_boxes(per_page, opts.paper, sw / sh);
|
||||
slides.chunks(boxes.len()).map(|c| c.iter().zip(&boxes).map(|(i, b)| (*i, *b)).collect()).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_outline(pres: &Presentation, pages: &[Vec<(usize, Rect)>]) -> Outline {
|
||||
let mut outline = Outline::new();
|
||||
for (p, v) in pages.iter().enumerate() {
|
||||
for (i, _) in v {
|
||||
let t = pres.slides[*i].title();
|
||||
let t = t.lines().next().unwrap_or("").trim().to_string();
|
||||
let label = if t.is_empty() { format!("Slide {}", i + 1) } else { format!("{}. {t}", i + 1) };
|
||||
outline.push_child(OutlineNode::new(label, XyzDestination::new(p, Point::from_xy(0.0, 0.0))));
|
||||
}
|
||||
}
|
||||
outline
|
||||
}
|
||||
|
||||
fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
let slides: Vec<usize> = match &opts.slides {
|
||||
Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(),
|
||||
@@ -153,29 +180,10 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0));
|
||||
let mut ex = Exporter { pres, opts, fonts: HashMap::new() };
|
||||
// Page plan: per page, the slides on it with their boxes (page points).
|
||||
let pages: Vec<Vec<(usize, Rect)>> = match opts.layout {
|
||||
PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(),
|
||||
PageLayout::Notes => {
|
||||
let b = notes_frames(pres, opts.paper).0;
|
||||
slides.iter().map(|&i| vec![(i, b)]).collect()
|
||||
}
|
||||
PageLayout::Handouts { per_page } => {
|
||||
let boxes = handout_boxes(per_page, opts.paper, sw / sh);
|
||||
slides.chunks(boxes.len()).map(|c| c.iter().zip(&boxes).map(|(i, b)| (*i, *b)).collect()).collect()
|
||||
}
|
||||
};
|
||||
let pages = plan_pages(pres, opts, &slides, sw, sh);
|
||||
// Bookmarks and slide → page lookup for internal links.
|
||||
let page_of: HashMap<usize, usize> = pages.iter().enumerate().flat_map(|(p, v)| v.iter().map(move |(i, _)| (*i, p))).collect();
|
||||
let mut outline = Outline::new();
|
||||
for (p, v) in pages.iter().enumerate() {
|
||||
for (i, _) in v {
|
||||
let t = pres.slides[*i].title();
|
||||
let t = t.lines().next().unwrap_or("").trim().to_string();
|
||||
let label = if t.is_empty() { format!("Slide {}", i + 1) } else { format!("{}. {t}", i + 1) };
|
||||
outline.push_child(OutlineNode::new(label, XyzDestination::new(p, Point::from_xy(0.0, 0.0))));
|
||||
}
|
||||
}
|
||||
pdf.set_outline(outline);
|
||||
pdf.set_outline(build_outline(pres, &pages));
|
||||
|
||||
for v in &pages {
|
||||
let (pw, ph) = match opts.layout {
|
||||
|
||||
@@ -76,7 +76,7 @@ impl Anim {
|
||||
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
|
||||
|
||||
fn with<R>(f: impl FnOnce(&mut Cache) -> R) -> R {
|
||||
let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
f(g.get_or_insert_with(Cache::default))
|
||||
}
|
||||
|
||||
|
||||
@@ -1090,7 +1090,7 @@ fn shape_item(
|
||||
for g in &glyphs {
|
||||
let Some(&h) = char_at_byte.get(&g.cluster) else { continue };
|
||||
let Some(cell) = cells.get_mut(h) else { continue };
|
||||
if matches!(cell.ch, '\t' | '\u{b}' | '\n') {
|
||||
if matches!(cell.ch, '\t'..='\u{b}') {
|
||||
continue;
|
||||
}
|
||||
let s = scale_of(cell);
|
||||
@@ -1738,7 +1738,7 @@ fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) {
|
||||
let mut w = 0.0;
|
||||
let mut dec = None;
|
||||
for c in cells.iter().skip(i + 1) {
|
||||
if matches!(c.ch, '\t' | '\u{b}' | '\n') {
|
||||
if matches!(c.ch, '\t'..='\u{b}') {
|
||||
break;
|
||||
}
|
||||
if c.ch == '.' && dec.is_none() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,7 @@ pub struct Backstage {
|
||||
}
|
||||
|
||||
impl Backstage {
|
||||
pub fn new(page: Page, docs: (usize, Option<usize>)) -> Backstage {
|
||||
pub fn new(page: Page, docs: (usize, Option<u64>)) -> Backstage {
|
||||
Backstage {
|
||||
page,
|
||||
docs,
|
||||
@@ -336,7 +336,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);
|
||||
@@ -439,7 +439,7 @@ fn nav_item(ui: &mut Ui, nav: Nav, bs: &Backstage, index: usize, t: &Tokens) ->
|
||||
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() {
|
||||
@@ -493,7 +493,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);
|
||||
@@ -597,7 +597,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);
|
||||
@@ -623,7 +623,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);
|
||||
}
|
||||
@@ -641,7 +641,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);
|
||||
@@ -656,7 +656,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);
|
||||
@@ -934,13 +934,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)));
|
||||
@@ -1055,7 +1060,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")]
|
||||
{
|
||||
@@ -1065,6 +1070,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:");
|
||||
@@ -1077,7 +1083,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;
|
||||
@@ -1085,27 +1091,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() {
|
||||
@@ -1231,7 +1248,7 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_
|
||||
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);
|
||||
}
|
||||
@@ -1278,7 +1295,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| {
|
||||
@@ -1288,6 +1305,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}));
|
||||
@@ -1299,7 +1317,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);
|
||||
@@ -1349,13 +1367,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.");
|
||||
|
||||
@@ -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;
|
||||
@@ -144,7 +150,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 +166,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 +247,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 +460,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 +490,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 +657,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 +725,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),
|
||||
@@ -1513,7 +1522,13 @@ mod tests {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
//! Bounded screenshot completion for the control channel.
|
||||
//!
|
||||
//! egui 0.37 replaced the `UserData` token and `Event::Screenshot` with a one-shot callback.
|
||||
//! The callback belongs to the viewport output and may run on a different thread; it must not
|
||||
//! write files or send replies. This app-owned registry is the strong owner of each admitted
|
||||
//! request and its completion slot, while the callback only holds a [`std::sync::Weak`] handle.
|
||||
//!
|
||||
//! Rules enforced here:
|
||||
//! - at most [`MAX_REQUESTS`] admitted requests;
|
||||
//! - at most [`MAX_IMAGE_BYTES`] for one `ColorImage` and [`MAX_TOTAL_BYTES`] for the peak
|
||||
//! memory of all in-flight/completed-but-undrained captures, including the RGBA copy, the
|
||||
//! renderer's straight-alpha copy, the PNG and (for an inline reply) its base64 text;
|
||||
//! - a request is issued only after the UI has settled for [`SETTLE`] and two real UI passes;
|
||||
//! - the geometry (logical size, DPI/zoom, viewport and epoch) is recorded in a real UI pass,
|
||||
//! never from stale `run_logic` input;
|
||||
//! - a completion is correlated by token and rejected before saving when its geometry epoch,
|
||||
//! image size or document generation no longer matches;
|
||||
//! - a late completion after cancellation/timeout is released without a file or reply.
|
||||
//!
|
||||
//! The one-shot callback wakes only the viewport that issued the screenshot, after the slot lock
|
||||
//! is released. While queued/settling requests are still waiting to be issued, logic asks a hidden
|
||||
//! viewport for the bounded paint passes they need; an issued request relies on the screenshot
|
||||
//! command and the callback wake, and a deadline wake keeps the normal timeout reachable. There is
|
||||
//! no per-frame 16 ms repaint timer.
|
||||
//!
|
||||
//! Web qualification note: the shared eframe fork used here has no `RequestPaintWhileHidden`
|
||||
//! support. A hidden browser tab may therefore not service `request_repaint_after`; hidden web
|
||||
//! capture is explicitly unqualified until that fork fix lands. Native hidden/occluded windows
|
||||
//! are the supported path.
|
||||
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{ColorImage, Context, ScreenshotCallback, Vec2, ViewportCommand, ViewportId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::control::ControlResponse;
|
||||
|
||||
/// Admitted screenshot requests at once.
|
||||
pub const MAX_REQUESTS: usize = 4;
|
||||
/// Bytes for one `ColorImage` (`width * height * 4`). Encoding copies are bounded by the
|
||||
/// aggregate budget below.
|
||||
pub const MAX_IMAGE_BYTES: usize = 128 << 20;
|
||||
/// Peak bytes reserved for all in-flight and completed-but-undrained captures, including the
|
||||
/// image, its RGBA/encoder copies, the PNG and an inline base64 reply.
|
||||
pub const MAX_TOTAL_BYTES: usize = 256 << 20;
|
||||
/// The window must have been visible and unchanged for this long before capture.
|
||||
pub const SETTLE: Duration = Duration::from_millis(120);
|
||||
/// Completed UI passes required after admission before capture.
|
||||
pub const SETTLED_PASSES: u32 = 2;
|
||||
/// A screenshot that has no completion by then fails, as before 0.37.
|
||||
pub const DEADLINE: Duration = Duration::from_secs(8);
|
||||
|
||||
/// The aggregate reservation after replacing an old reservation with a new one, or `None` when
|
||||
/// it would exceed [`MAX_TOTAL_BYTES`]. The limit itself is inclusive.
|
||||
fn budget_total(reserved: usize, replacing: usize, bytes: usize) -> Option<usize> {
|
||||
let total = reserved.checked_sub(replacing)?.checked_add(bytes)?;
|
||||
(total <= MAX_TOTAL_BYTES).then_some(total)
|
||||
}
|
||||
|
||||
/// Every allocation a screenshot can hold at its peak.
|
||||
///
|
||||
/// `save_screenshot` makes one RGBA copy from the `ColorImage`, `Image::to_png` clones that copy
|
||||
/// again while straightening alpha, and the PNG encoder writes its own buffer. An inline reply
|
||||
/// (`path` absent) additionally base64-encodes the PNG. Reserving the sum, not just the raw
|
||||
/// pixels, keeps the 256 MiB aggregate honest during encoding.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MemoryPlan {
|
||||
/// The callback's `ColorImage` (`width * height * 4`).
|
||||
pub color_image: usize,
|
||||
/// The RGBA `Vec<u8>` handed to `deckcraft_render::Image`.
|
||||
pub rgba: usize,
|
||||
/// `Image::to_png`'s straight-alpha copy.
|
||||
pub encoder_copy: usize,
|
||||
/// Conservative PNG output bound (raw bytes plus deflate/header slack).
|
||||
pub png: usize,
|
||||
/// Inline base64 text; zero when the request writes a path instead.
|
||||
pub inline: usize,
|
||||
/// Peak bytes held at once: all of the above.
|
||||
pub peak: usize,
|
||||
}
|
||||
|
||||
impl MemoryPlan {
|
||||
/// Checked plan for an exact pixel size. `None` for zero, non-`u32`, or overflowing images.
|
||||
pub fn for_image(width: usize, height: usize, inline_response: bool) -> Option<Self> {
|
||||
if u32::try_from(width).is_err() || u32::try_from(height).is_err() {
|
||||
return None;
|
||||
}
|
||||
let color_image = width.checked_mul(height)?.checked_mul(std::mem::size_of::<egui::Color32>())?;
|
||||
if color_image == 0 {
|
||||
return None;
|
||||
}
|
||||
let rgba = color_image;
|
||||
let encoder_copy = color_image;
|
||||
// The image crate's PNG encoder cannot expand past the raw scanlines, one filter byte
|
||||
// per row, zlib block headers and chunk framing. 1% plus that row overhead and 1 KiB is
|
||||
// comfortably above the worst case, including a one-pixel-wide very tall image.
|
||||
let png = color_image.checked_add(height)?.checked_add(color_image / 100)?.checked_add(1024)?;
|
||||
let mut peak = color_image.checked_mul(3)?.checked_add(png)?;
|
||||
let inline = if inline_response {
|
||||
let base64 = png.checked_add(2)?.checked_div(3)?.checked_mul(4)?;
|
||||
peak = peak.checked_add(base64)?;
|
||||
base64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some(Self { color_image, rgba, encoder_copy, png, inline, peak })
|
||||
}
|
||||
|
||||
/// Checked plan for a reserved viewport geometry.
|
||||
pub fn for_geometry(geometry: Geometry, inline_response: bool) -> Option<Self> {
|
||||
let [width, height] = geometry.pixel_size()?;
|
||||
Self::for_image(width, height, inline_response)
|
||||
}
|
||||
}
|
||||
|
||||
/// Geometry recorded from a real UI pass. `epoch` advances on every size, DPI/zoom or viewport
|
||||
/// change, including a change back, so a stale image cannot be relabelled with fresh bounds.
|
||||
///
|
||||
/// eframe 0.37 exposes no surface-generation identity: a same-size surface recreation cannot be
|
||||
/// distinguished from an unchanged surface. That remains an explicit native qualification gap.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Geometry {
|
||||
pub size: Vec2,
|
||||
pub pixels_per_point: f32,
|
||||
pub viewport: ViewportId,
|
||||
pub epoch: u64,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
/// Physical pixels the viewport should return, or `None` for a zero/NaN/hostile size.
|
||||
pub fn pixel_size(self) -> Option<[usize; 2]> {
|
||||
if !self.size.x.is_finite() || !self.size.y.is_finite() || !self.pixels_per_point.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let w = (self.size.x.max(0.0) as f64 * self.pixels_per_point as f64).round();
|
||||
let h = (self.size.y.max(0.0) as f64 * self.pixels_per_point as f64).round();
|
||||
if w < 1.0 || h < 1.0 || w > usize::MAX as f64 || h > usize::MAX as f64 {
|
||||
return None;
|
||||
}
|
||||
Some([w as usize, h as usize])
|
||||
}
|
||||
|
||||
/// Checked bytes for a reserved image.
|
||||
pub fn image_bytes(self) -> Option<usize> {
|
||||
let [w, h] = self.pixel_size()?;
|
||||
w.checked_mul(h)?.checked_mul(std::mem::size_of::<egui::Color32>())
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds a callback image must satisfy before it is stored. `max_image_bytes` is the
|
||||
/// `ColorImage` share of the request's reservation for the geometry its screenshot was issued
|
||||
/// against; the aggregate reservation also covers the copies and encoding in [`MemoryPlan`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CaptureBounds {
|
||||
pub max_image_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SlotState {
|
||||
Pending,
|
||||
Ready(Arc<ColorImage>),
|
||||
/// A frame outside the reserved capture geometry. It is not stored; drain retries it until
|
||||
/// the request's normal deadline, so an oversized image is never retained.
|
||||
Retry(&'static str),
|
||||
Rejected(&'static str),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Completion slot shared by the registry (strong) and the callback (weak).
|
||||
#[derive(Debug)]
|
||||
pub struct CaptureSlot {
|
||||
token: u64,
|
||||
state: Mutex<SlotState>,
|
||||
}
|
||||
|
||||
impl CaptureSlot {
|
||||
fn new(token: u64) -> Self {
|
||||
Self { token, state: Mutex::new(SlotState::Pending) }
|
||||
}
|
||||
|
||||
/// Called only by the one-shot egui callback. Never writes files or sends replies.
|
||||
///
|
||||
/// The image's byte size is checked against the reservation for the issued geometry *before*
|
||||
/// the pixels are stored. A larger image is a retryable resize, not a permanent user error.
|
||||
/// A smaller image is kept so [`Capture::drain`] can compare the actual size exactly and
|
||||
/// retry instead of saving a frame from an older surface. Returns true when this completion
|
||||
/// changed the slot, so the callback can wake the viewport after the lock is released.
|
||||
pub fn complete(&self, token: u64, bounds: CaptureBounds, image: Arc<ColorImage>) -> bool {
|
||||
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if token != self.token || !matches!(*state, SlotState::Pending) {
|
||||
return false;
|
||||
}
|
||||
let [width, height] = image.size;
|
||||
if width == 0 || height == 0 {
|
||||
*state = SlotState::Rejected("the screenshot was empty");
|
||||
return true;
|
||||
}
|
||||
let bytes = width.checked_mul(height).and_then(|pixels| pixels.checked_mul(std::mem::size_of::<egui::Color32>()));
|
||||
match bytes {
|
||||
Some(bytes) if bytes <= bounds.max_image_bytes => *state = SlotState::Ready(image),
|
||||
_ => *state = SlotState::Retry("the screenshot was larger than the reserved window"),
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn take(&self) -> SlotResult {
|
||||
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
match std::mem::replace(&mut *state, SlotState::Pending) {
|
||||
SlotState::Pending => SlotResult::Pending,
|
||||
SlotState::Ready(image) => SlotResult::Ready(image),
|
||||
SlotState::Retry(why) => SlotResult::Retry(why),
|
||||
SlotState::Rejected(why) => SlotResult::Rejected(why),
|
||||
SlotState::Cancelled => SlotResult::Cancelled,
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&self) {
|
||||
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*state = SlotState::Cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SlotResult {
|
||||
Pending,
|
||||
Ready(Arc<ColorImage>),
|
||||
Retry(&'static str),
|
||||
Rejected(&'static str),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// The one-shot callback wakes only the viewport that issued this screenshot. `complete` has
|
||||
/// already released the slot lock when this runs, so no lock is held across the wake.
|
||||
fn completion_callback(ctx: Context, viewport: ViewportId, weak: Weak<CaptureSlot>, token: u64, bounds: CaptureBounds) -> ScreenshotCallback {
|
||||
ScreenshotCallback::new(move |image| {
|
||||
let Some(slot) = weak.upgrade() else { return };
|
||||
if slot.complete(token, bounds, image) {
|
||||
ctx.request_repaint_of(viewport);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Issued {
|
||||
geometry: Geometry,
|
||||
}
|
||||
|
||||
struct Request {
|
||||
token: u64,
|
||||
path: Option<String>,
|
||||
reply: std::sync::mpsc::Sender<ControlResponse>,
|
||||
slot: Arc<CaptureSlot>,
|
||||
admitted: web_time::Instant,
|
||||
deadline: web_time::Instant,
|
||||
ui_passes_at_admission: u64,
|
||||
doc_generation: Option<u64>,
|
||||
issued: Option<Issued>,
|
||||
/// Peak bytes reserved for this request. A queued request is reserved from the geometry
|
||||
/// known at admission and rebooked when the window resizes; an issued request keeps the
|
||||
/// reservation of the geometry whose callback is in flight.
|
||||
reservation: usize,
|
||||
}
|
||||
|
||||
/// One completed screenshot handed to the app thread for saving and replying.
|
||||
pub struct Completed {
|
||||
pub token: u64,
|
||||
pub path: Option<String>,
|
||||
pub reply: std::sync::mpsc::Sender<ControlResponse>,
|
||||
pub image: Arc<ColorImage>,
|
||||
pub geometry: Geometry,
|
||||
/// Still reserved while the app encodes/writes it; release after saving.
|
||||
pub reserved: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Capture {
|
||||
next_token: u64,
|
||||
requests: Vec<Request>,
|
||||
geometry: Option<Geometry>,
|
||||
settled_passes: u32,
|
||||
ui_passes: u64,
|
||||
reserved: usize,
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.requests.len() >= MAX_REQUESTS
|
||||
}
|
||||
|
||||
pub fn pending(&self) -> usize {
|
||||
self.requests.len()
|
||||
}
|
||||
|
||||
/// Peak bytes reserved for in-flight and completed-but-undrained screenshots.
|
||||
pub fn reserved_bytes(&self) -> usize {
|
||||
self.reserved
|
||||
}
|
||||
|
||||
/// Admit one control request, reserving its peak encode memory when the UI geometry is
|
||||
/// already known. The caller replies with the returned error when admission is refused.
|
||||
pub fn admit(
|
||||
&mut self,
|
||||
path: Option<String>,
|
||||
reply: std::sync::mpsc::Sender<ControlResponse>,
|
||||
doc_generation: Option<u64>,
|
||||
now: web_time::Instant,
|
||||
) -> Result<(), &'static str> {
|
||||
if self.is_full() {
|
||||
return Err("busy: at most 4 screenshots may be pending");
|
||||
}
|
||||
let reservation = match self.geometry {
|
||||
Some(geometry) => {
|
||||
let plan = MemoryPlan::for_geometry(geometry, path.is_none()).ok_or("the window has no usable surface size")?;
|
||||
if plan.color_image > MAX_IMAGE_BYTES {
|
||||
return Err("the screenshot would exceed 128 MiB; use ui.render");
|
||||
}
|
||||
let total = budget_total(self.reserved, 0, plan.peak)
|
||||
.ok_or("screenshot budget exhausted (4 requests / 256 MiB including encode); wait for one to finish")?;
|
||||
self.reserved = total;
|
||||
plan.peak
|
||||
}
|
||||
// No UI pass yet: the first settled pass books the real geometry in `issue`.
|
||||
None => 0,
|
||||
};
|
||||
self.next_token = self.next_token.wrapping_add(1).max(1);
|
||||
let token = self.next_token;
|
||||
self.requests.push(Request {
|
||||
token,
|
||||
path,
|
||||
reply,
|
||||
slot: Arc::new(CaptureSlot::new(token)),
|
||||
admitted: now,
|
||||
deadline: now + DEADLINE,
|
||||
ui_passes_at_admission: self.ui_passes,
|
||||
doc_generation,
|
||||
issued: None,
|
||||
reservation,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record the geometry of the pass that just laid out the real UI. A change resets the
|
||||
/// settle counter, advances the epoch, and rebooks queued (not in-flight) captures at the
|
||||
/// new peak size so the aggregate remains accurate during a resize.
|
||||
pub fn observe_ui(&mut self, ctx: &Context) {
|
||||
// The screenshot callback returns the whole framebuffer, not the safe-area content
|
||||
// rect: on web/iOS `content_rect` excludes status-bar/notch insets and would make every
|
||||
// completion look like a resize.
|
||||
let size = ctx.viewport_rect().size();
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let viewport = ctx.viewport_id();
|
||||
let changed = self.geometry.is_none_or(|old| old.size != size || old.pixels_per_point != ppp || old.viewport != viewport);
|
||||
if changed {
|
||||
let epoch = self.geometry.map_or(1, |g| g.epoch.wrapping_add(1));
|
||||
self.geometry = Some(Geometry { size, pixels_per_point: ppp, viewport, epoch });
|
||||
self.settled_passes = 1;
|
||||
self.rebook_unissued();
|
||||
} else {
|
||||
self.settled_passes = self.settled_passes.saturating_add(1);
|
||||
}
|
||||
self.ui_passes = self.ui_passes.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Update reservations of requests whose screenshot has not been requested yet.
|
||||
fn rebook_unissued(&mut self) {
|
||||
let geometry = self.geometry;
|
||||
let mut i = 0;
|
||||
while i < self.requests.len() {
|
||||
if self.requests[i].issued.is_some() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let desired = match geometry {
|
||||
Some(geometry) => match MemoryPlan::for_geometry(geometry, self.requests[i].path.is_none()) {
|
||||
Some(plan) if plan.color_image <= MAX_IMAGE_BYTES => plan.peak,
|
||||
_ => {
|
||||
self.fail(i, "the window has no usable surface size");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None => 0,
|
||||
};
|
||||
let old = self.requests[i].reservation;
|
||||
match budget_total(self.reserved, old, desired) {
|
||||
Some(total) => {
|
||||
self.reserved = total;
|
||||
self.requests[i].reservation = desired;
|
||||
i += 1;
|
||||
}
|
||||
None => self.fail(i, "screenshot budget exhausted (4 requests / 256 MiB including encode); wait for one to finish"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue due requests from the end of a real, settled UI pass.
|
||||
///
|
||||
/// A request is failed here (with a single JSON reply) when the document changed, the
|
||||
/// window has no usable surface, or the bounded memory budget would be exceeded.
|
||||
pub fn issue(&mut self, ctx: &Context, doc_generation: Option<u64>, now: web_time::Instant) {
|
||||
let Some(geometry) = self.geometry else { return };
|
||||
if self.settled_passes < SETTLED_PASSES {
|
||||
return;
|
||||
}
|
||||
let mut failures: Vec<(usize, String)> = vec![];
|
||||
for (i, req) in self.requests.iter_mut().enumerate() {
|
||||
if req.issued.is_some()
|
||||
|| now.saturating_duration_since(req.admitted) < SETTLE
|
||||
|| self.ui_passes.saturating_sub(req.ui_passes_at_admission) < u64::from(SETTLED_PASSES)
|
||||
|| self.settled_passes < SETTLED_PASSES
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if req.doc_generation != doc_generation {
|
||||
failures.push((i, "the presentation changed while the screenshot was waiting".into()));
|
||||
continue;
|
||||
}
|
||||
let Some(plan) = MemoryPlan::for_geometry(geometry, req.path.is_none()) else {
|
||||
failures.push((i, "the window has no usable surface size".into()));
|
||||
continue;
|
||||
};
|
||||
if plan.color_image > MAX_IMAGE_BYTES {
|
||||
failures.push((i, "the screenshot would exceed 128 MiB; use ui.render".into()));
|
||||
continue;
|
||||
}
|
||||
let Some(new_total) = budget_total(self.reserved, req.reservation, plan.peak) else {
|
||||
failures.push((i, "screenshot budget exhausted (4 requests / 256 MiB including encode); wait for one to finish".into()));
|
||||
continue;
|
||||
};
|
||||
let weak: Weak<CaptureSlot> = Arc::downgrade(&req.slot);
|
||||
let callback = completion_callback(ctx.clone(), geometry.viewport, weak, req.token, CaptureBounds { max_image_bytes: plan.color_image });
|
||||
ctx.send_viewport_cmd_to(geometry.viewport, ViewportCommand::Screenshot(callback));
|
||||
self.reserved = new_total;
|
||||
req.reservation = plan.peak;
|
||||
req.issued = Some(Issued { geometry });
|
||||
}
|
||||
for (i, why) in failures.into_iter().rev() {
|
||||
self.fail(i, &why);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take completed results and expired/cancelled requests. A completed image keeps its
|
||||
/// reservation until [`Self::release`] is called after the app has encoded or written it.
|
||||
pub fn drain(&mut self, now: web_time::Instant, doc_generation: Option<u64>) -> Vec<Completed> {
|
||||
let mut done = vec![];
|
||||
let current_epoch = self.geometry.map(|g| g.epoch);
|
||||
let mut i = 0;
|
||||
while i < self.requests.len() {
|
||||
// A completion that arrived just before the deadline is still valid: check the slot
|
||||
// first and only fail on the deadline when nothing is ready. Otherwise the deadline
|
||||
// branch below would drop a frame that is already in hand.
|
||||
let result = if self.requests[i].issued.is_some() { self.requests[i].slot.take() } else { SlotResult::Pending };
|
||||
match result {
|
||||
SlotResult::Pending => {
|
||||
if now >= self.requests[i].deadline {
|
||||
self.fail(i, "no frame was presented (screen locked or window hidden); use ui.render");
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
SlotResult::Retry(why) => {
|
||||
// The callback refused to retain an image outside the reservation. Retry it
|
||||
// against the current geometry until the normal deadline, exactly like a
|
||||
// size mismatch; an expired request fails with the same reason.
|
||||
let Some(_issued) = self.requests[i].issued.take() else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
self.release_request(i);
|
||||
if now >= self.requests[i].deadline {
|
||||
self.fail(i, why);
|
||||
} else {
|
||||
self.requests[i].slot = Arc::new(CaptureSlot::new(self.requests[i].token));
|
||||
self.requests[i].ui_passes_at_admission = self.ui_passes;
|
||||
self.requests[i].admitted = now;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
SlotResult::Cancelled => self.fail(i, "screenshot was cancelled"),
|
||||
SlotResult::Rejected(why) => self.fail(i, why),
|
||||
SlotResult::Ready(image) => {
|
||||
let Some(issued) = self.requests[i].issued.take() else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let same_geometry = current_epoch == Some(issued.geometry.epoch)
|
||||
&& self.geometry.is_some_and(|g| {
|
||||
g.size == issued.geometry.size
|
||||
&& g.pixels_per_point == issued.geometry.pixels_per_point
|
||||
&& g.viewport == issued.geometry.viewport
|
||||
});
|
||||
let exact_size = issued.geometry.pixel_size() == Some(image.size);
|
||||
if !same_geometry || !exact_size {
|
||||
// The window changed (or the image came from an earlier size) while
|
||||
// capturing: retry with fresh geometry and the same deadline. This is not
|
||||
// a document change and writes no file.
|
||||
self.release_request(i);
|
||||
if now >= self.requests[i].deadline {
|
||||
self.fail(i, "the window kept resizing while the screenshot was being captured");
|
||||
} else {
|
||||
self.requests[i].slot = Arc::new(CaptureSlot::new(self.requests[i].token));
|
||||
self.requests[i].ui_passes_at_admission = self.ui_passes;
|
||||
self.requests[i].admitted = now;
|
||||
i += 1;
|
||||
}
|
||||
} else if self.requests[i].doc_generation != doc_generation {
|
||||
self.fail(i, "the presentation changed while the screenshot was being captured");
|
||||
} else {
|
||||
let reservation = self.requests[i].reservation;
|
||||
self.requests[i].reservation = 0;
|
||||
let req = self.requests.remove(i);
|
||||
done.push(Completed {
|
||||
token: req.token,
|
||||
path: req.path,
|
||||
reply: req.reply,
|
||||
image,
|
||||
geometry: issued.geometry,
|
||||
reserved: reservation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
/// Release the reservation for a completed screenshot after the app has encoded or written
|
||||
/// it and replied. The aggregate stays charged through the encode to keep its peak honest.
|
||||
pub fn release(&mut self, bytes: usize) {
|
||||
self.reserved = self.reserved.saturating_sub(bytes);
|
||||
}
|
||||
|
||||
/// Drop one request's reservation but keep it queued (used by a retry).
|
||||
fn release_request(&mut self, i: usize) {
|
||||
let bytes = self.requests[i].reservation;
|
||||
self.requests[i].reservation = 0;
|
||||
self.release(bytes);
|
||||
}
|
||||
|
||||
/// Remove a request at `i`, release its reservation and reply exactly once.
|
||||
fn fail(&mut self, i: usize, why: &str) {
|
||||
self.release_request(i);
|
||||
let req = self.requests.remove(i);
|
||||
req.slot.cancel();
|
||||
let _ = req.reply.send(json!({"ok": false, "error": why}));
|
||||
}
|
||||
|
||||
/// Test-only: make the settle deadline already pass without sleeping.
|
||||
#[cfg(test)]
|
||||
pub fn force_settle(&mut self) {
|
||||
let now = web_time::Instant::now();
|
||||
for req in &mut self.requests {
|
||||
req.admitted = now - SETTLE - Duration::from_millis(1);
|
||||
req.ui_passes_at_admission = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the issuing viewport for the bounded work a pending screenshot still needs.
|
||||
///
|
||||
/// Queued/settling requests need real hidden paints (`RequestPaintWhileHidden`) until they
|
||||
/// are issued. Once issued, the screenshot command itself requests the capture paint and the
|
||||
/// completion callback wakes the viewport; only a deadline timer is scheduled here, so a
|
||||
/// missed hidden frame still fails through the normal request deadline instead of a 16 ms
|
||||
/// repaint loop.
|
||||
pub fn request_paint_if_needed(&self, ctx: &Context, now: web_time::Instant) {
|
||||
if self.requests.is_empty() {
|
||||
return;
|
||||
}
|
||||
let viewport = self.geometry.map_or_else(|| ctx.viewport_id(), |g| g.viewport);
|
||||
if self.requests.iter().any(|req| req.issued.is_none()) {
|
||||
ctx.send_viewport_cmd_to(viewport, ViewportCommand::RequestPaintWhileHidden);
|
||||
}
|
||||
if let Some(deadline) = self.requests.iter().map(|req| req.deadline).min() {
|
||||
let wait = deadline.saturating_duration_since(now).max(Duration::from_millis(1));
|
||||
ctx.request_repaint_after_for(wait, viewport);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Capture {
|
||||
fn drop(&mut self) {
|
||||
for req in &self.requests {
|
||||
req.slot.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn image(w: usize, h: usize) -> Arc<ColorImage> {
|
||||
Arc::new(ColorImage::new([w, h], vec![egui::Color32::RED; w * h]))
|
||||
}
|
||||
|
||||
fn setup(cap: &mut Capture, now: web_time::Instant, path: Option<&str>) -> (u64, std::sync::mpsc::Receiver<ControlResponse>) {
|
||||
let (req, rx) = crate::ControlRequest::new("ui.screenshot", json!({}));
|
||||
cap.admit(path.map(str::to_string), req.reply.clone(), Some(7), now).unwrap();
|
||||
(cap.requests.last().unwrap().token, rx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_is_bounded_and_late_cancel_keeps_no_image() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
for _ in 0..MAX_REQUESTS {
|
||||
setup(&mut cap, now, None);
|
||||
}
|
||||
assert!(cap.is_full(), "four admitted requests fill the registry");
|
||||
let slot = Arc::clone(&cap.requests[0].slot);
|
||||
slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, image(2, 2));
|
||||
slot.cancel();
|
||||
assert!(matches!(slot.take(), SlotResult::Cancelled), "a completion after cancel retains nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_shot_token_correlation_ignores_a_foreign_completion() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
let (token, _rx) = setup(&mut cap, now, Some("first.png"));
|
||||
let slot = Arc::clone(&cap.requests[0].slot);
|
||||
slot.complete(token + 1, CaptureBounds { max_image_bytes: 16 }, image(2, 2));
|
||||
assert!(matches!(slot.take(), SlotResult::Pending), "another token cannot complete this slot");
|
||||
slot.complete(token, CaptureBounds { max_image_bytes: 16 }, image(2, 2));
|
||||
assert!(matches!(slot.take(), SlotResult::Ready(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_images_are_rejected_and_other_sizes_are_kept_for_the_drain() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
let (token, _rx) = setup(&mut cap, now, None);
|
||||
let slot = Arc::clone(&cap.requests[0].slot);
|
||||
slot.complete(token, CaptureBounds { max_image_bytes: 36 }, image(0, 2));
|
||||
assert!(matches!(slot.take(), SlotResult::Rejected(_)), "zero width");
|
||||
slot.complete(token, CaptureBounds { max_image_bytes: 36 }, image(3, 3));
|
||||
let taken = slot.take();
|
||||
assert!(matches!(&taken, SlotResult::Ready(image) if image.size == [3, 3]), "the actual size reaches drain");
|
||||
}
|
||||
|
||||
/// A smaller image from a resize that has not been observed yet must not be saved just
|
||||
/// because it fits the byte bound; drain compares the exact pixel size and retries.
|
||||
#[test]
|
||||
fn an_image_of_a_different_size_retries_instead_of_saving() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
let geometry = Geometry { size: Vec2::new(4.0, 4.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 };
|
||||
cap.geometry = Some(geometry);
|
||||
cap.settled_passes = SETTLED_PASSES;
|
||||
cap.ui_passes = 10;
|
||||
let (_token, rx) = setup(&mut cap, now, Some("resized.png"));
|
||||
cap.requests[0].issued = Some(Issued { geometry });
|
||||
cap.requests[0].reservation = 64;
|
||||
cap.reserved = 64;
|
||||
let slot = Arc::clone(&cap.requests[0].slot);
|
||||
slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2));
|
||||
let done = cap.drain(now, Some(7));
|
||||
assert!(done.is_empty(), "a stale-size image is not written");
|
||||
assert_eq!(cap.pending(), 1, "the request is retried");
|
||||
assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released");
|
||||
assert!(rx.try_recv().is_err(), "no premature error reply");
|
||||
}
|
||||
|
||||
/// A completion that is ready when the deadline has just passed is still a valid frame.
|
||||
#[test]
|
||||
fn a_ready_image_wins_over_the_deadline() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 };
|
||||
cap.geometry = Some(geometry);
|
||||
cap.settled_passes = SETTLED_PASSES;
|
||||
let (_token, _rx) = setup(&mut cap, now, Some("late.png"));
|
||||
cap.requests[0].issued = Some(Issued { geometry });
|
||||
cap.requests[0].reservation = 16;
|
||||
cap.reserved = 16;
|
||||
let slot = Arc::clone(&cap.requests[0].slot);
|
||||
slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2));
|
||||
let done = cap.drain(now + DEADLINE + Duration::from_millis(1), Some(7));
|
||||
assert_eq!(done.len(), 1, "the completed frame is delivered");
|
||||
assert_eq!(done[0].path.as_deref(), Some("late.png"));
|
||||
assert_eq!(done[0].reserved, 16);
|
||||
assert_eq!(cap.pending(), 0);
|
||||
assert_eq!(cap.reserved_bytes(), 16, "the encode reservation is still held");
|
||||
cap.release(done[0].reserved);
|
||||
assert_eq!(cap.reserved_bytes(), 0);
|
||||
}
|
||||
|
||||
/// Fractional DPI rounds the framebuffer size once, on the reserved geometry only.
|
||||
#[test]
|
||||
fn fractional_dpi_rounds_to_the_reserved_pixel_size() {
|
||||
let g = Geometry { size: Vec2::new(101.0, 51.0), pixels_per_point: 1.5, viewport: ViewportId::ROOT, epoch: 1 };
|
||||
assert_eq!(g.pixel_size(), Some([152, 77]));
|
||||
let accepted = |image: [usize; 2]| g.pixel_size() == Some(image);
|
||||
assert!(accepted([152, 77]));
|
||||
assert!(!accepted([151, 76]), "a different framebuffer size is a resize, not a fit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgets_use_checked_geometry_and_reject_zero_or_nan_sizes() {
|
||||
let g = Geometry { size: Vec2::new(100.0, 50.0), pixels_per_point: 2.0, viewport: ViewportId::ROOT, epoch: 1 };
|
||||
assert_eq!(g.pixel_size(), Some([200, 100]));
|
||||
assert_eq!(g.image_bytes(), Some(200 * 100 * 4));
|
||||
assert_eq!(Geometry { size: Vec2::new(0.0, 50.0), ..g }.image_bytes(), None);
|
||||
assert_eq!(Geometry { size: Vec2::new(f32::NAN, 50.0), ..g }.image_bytes(), None);
|
||||
assert_eq!(Geometry { size: Vec2::new(f32::MAX, f32::MAX), pixels_per_point: f32::MAX, ..g }.image_bytes(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_geometry_epoch_change_retries_instead_of_saving() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
// Fake a settled UI pass and issue the request against it.
|
||||
let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 4 };
|
||||
cap.geometry = Some(geometry);
|
||||
cap.settled_passes = SETTLED_PASSES;
|
||||
cap.ui_passes = 10;
|
||||
let (_token, _rx) = setup(&mut cap, now, Some("retry.png"));
|
||||
cap.requests[0].issued = Some(Issued { geometry });
|
||||
cap.requests[0].reservation = 16;
|
||||
cap.reserved = 16;
|
||||
// The geometry changed before the completion arrived.
|
||||
cap.geometry = Some(Geometry { epoch: 5, ..geometry });
|
||||
let slot = Arc::clone(&cap.requests[0].slot);
|
||||
slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2));
|
||||
let done = cap.drain(now, Some(7));
|
||||
assert!(done.is_empty(), "stale geometry is not written");
|
||||
assert_eq!(cap.pending(), 1, "the request is retried");
|
||||
assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released");
|
||||
assert!(cap.requests[0].issued.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_change_fails_before_writing() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 };
|
||||
cap.geometry = Some(geometry);
|
||||
cap.settled_passes = SETTLED_PASSES;
|
||||
let (_token, rx) = setup(&mut cap, now, Some("doc.png"));
|
||||
cap.requests[0].issued = Some(Issued { geometry });
|
||||
cap.requests[0].reservation = 16;
|
||||
cap.reserved = 16;
|
||||
let slot = Arc::clone(&cap.requests[0].slot);
|
||||
slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2));
|
||||
let done = cap.drain(now, Some(8));
|
||||
assert!(done.is_empty());
|
||||
assert_eq!(cap.pending(), 0);
|
||||
let reply = rx.recv().unwrap();
|
||||
assert_eq!(reply["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_replies_once_and_releases_the_reservation() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 };
|
||||
cap.geometry = Some(geometry);
|
||||
cap.settled_passes = SETTLED_PASSES;
|
||||
let (_token, rx) = setup(&mut cap, now, None);
|
||||
cap.requests[0].issued = Some(Issued { geometry });
|
||||
cap.requests[0].reservation = 16;
|
||||
cap.reserved = 16;
|
||||
let done = cap.drain(now + DEADLINE, Some(7));
|
||||
assert!(done.is_empty());
|
||||
assert_eq!(cap.pending(), 0);
|
||||
assert_eq!(cap.reserved_bytes(), 0);
|
||||
assert_eq!(rx.recv().unwrap()["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_tokens_keep_distinct_paths_even_when_completed_out_of_order() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 };
|
||||
cap.geometry = Some(geometry);
|
||||
cap.settled_passes = SETTLED_PASSES;
|
||||
setup(&mut cap, now, Some("a.png"));
|
||||
setup(&mut cap, now, Some("b.png"));
|
||||
let plan = MemoryPlan::for_image(2, 2, false).expect("path capture plan");
|
||||
for req in &mut cap.requests {
|
||||
req.issued = Some(Issued { geometry });
|
||||
req.reservation = plan.peak;
|
||||
}
|
||||
cap.reserved = 2 * plan.peak;
|
||||
let bounds = CaptureBounds { max_image_bytes: plan.color_image };
|
||||
let second = Arc::clone(&cap.requests[1].slot);
|
||||
second.complete(cap.requests[1].token, bounds, image(2, 2));
|
||||
let done = cap.drain(now, Some(7));
|
||||
assert_eq!(done.len(), 1);
|
||||
assert_eq!(done[0].path.as_deref(), Some("b.png"));
|
||||
assert_eq!(cap.pending(), 1, "the unfinished first request continues");
|
||||
assert_eq!(cap.reserved_bytes(), 2 * plan.peak, "completed images stay reserved through encoding");
|
||||
cap.release(done[0].reserved);
|
||||
assert_eq!(cap.reserved_bytes(), plan.peak, "only the saved second capture is released");
|
||||
let first = Arc::clone(&cap.requests[0].slot);
|
||||
first.complete(cap.requests[0].token, bounds, image(2, 2));
|
||||
let done = cap.drain(now, Some(7));
|
||||
assert_eq!(done.len(), 1);
|
||||
assert_eq!(done[0].path.as_deref(), Some("a.png"));
|
||||
cap.release(done[0].reserved);
|
||||
assert_eq!(cap.reserved_bytes(), 0);
|
||||
}
|
||||
|
||||
/// The reservation counts the callback image, the RGBA copy, the encoder's straight-alpha
|
||||
/// copy, the PNG bound (raw + row filters) and (only for an inline reply) its base64 text.
|
||||
#[test]
|
||||
fn memory_plan_counts_image_rgba_png_and_inline_base64() {
|
||||
let path = MemoryPlan::for_image(2, 2, false).expect("path plan");
|
||||
assert_eq!(path.color_image, 16);
|
||||
assert_eq!(path.rgba, 16);
|
||||
assert_eq!(path.encoder_copy, 16);
|
||||
assert_eq!(path.png, 16 + 2 + 1024, "raw, one filter byte per row and encoder slack");
|
||||
assert_eq!(path.inline, 0, "a path reply does not hold base64");
|
||||
assert_eq!(path.peak, 3 * 16 + path.png);
|
||||
|
||||
let inline = MemoryPlan::for_image(2, 2, true).expect("inline plan");
|
||||
assert_eq!(inline.png, path.png);
|
||||
assert_eq!(inline.inline, path.png.div_ceil(3) * 4);
|
||||
assert_eq!(inline.peak, path.peak + inline.inline);
|
||||
|
||||
assert_eq!(MemoryPlan::for_image(0, 2, false), None, "zero width");
|
||||
assert_eq!(MemoryPlan::for_image(usize::MAX, 2, false), None, "overflowing width");
|
||||
assert_eq!(MemoryPlan::for_image(usize::MAX / 4, 4, false), None, "pixel-byte overflow");
|
||||
}
|
||||
|
||||
/// Admission reserves the peak before the first UI pass can issue the screenshot, and a
|
||||
/// second request that would cross the aggregate is refused without changing the total.
|
||||
#[test]
|
||||
fn admission_reserves_the_peak_and_refuses_the_one_that_would_exceed_it() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
let geometry = Geometry { size: Vec2::new(100.0, 100.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 };
|
||||
cap.geometry = Some(geometry);
|
||||
let plan = MemoryPlan::for_geometry(geometry, true).expect("plan");
|
||||
cap.reserved = MAX_TOTAL_BYTES - plan.peak;
|
||||
setup(&mut cap, now, None);
|
||||
assert_eq!(cap.reserved_bytes(), MAX_TOTAL_BYTES, "the exact limit is admitted");
|
||||
let (req, rx) = crate::ControlRequest::new("ui.screenshot", json!({}));
|
||||
assert!(cap.admit(None, req.reply, Some(7), now).is_err(), "one more peak does not fit");
|
||||
assert_eq!(cap.reserved_bytes(), MAX_TOTAL_BYTES, "a refused admission changes nothing");
|
||||
assert!(rx.try_recv().is_err(), "the caller sends the refusal reply");
|
||||
}
|
||||
|
||||
/// A resize rebooks a queued (not in-flight) request at the new peak; the old reservation
|
||||
/// is released first, so replacement never double-counts it.
|
||||
#[test]
|
||||
fn rebooking_at_a_new_geometry_replaces_the_old_reservation() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
let geometry = Geometry { size: Vec2::new(100.0, 100.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 };
|
||||
cap.geometry = Some(geometry);
|
||||
setup(&mut cap, now, Some("resize.png"));
|
||||
let small = MemoryPlan::for_geometry(geometry, false).expect("small plan");
|
||||
assert_eq!(cap.reserved_bytes(), small.peak);
|
||||
let bigger = Geometry { size: Vec2::new(200.0, 200.0), ..geometry };
|
||||
cap.geometry = Some(bigger);
|
||||
cap.rebook_unissued();
|
||||
let large = MemoryPlan::for_geometry(bigger, false).expect("large plan");
|
||||
assert_eq!(cap.reserved_bytes(), large.peak, "the new peak replaces the old one");
|
||||
assert_eq!(cap.requests[0].reservation, large.peak);
|
||||
}
|
||||
|
||||
/// The aggregate limit is inclusive: exactly 256 MiB is admitted; one byte more is refused.
|
||||
/// Replacing a request's own reservation does not count it twice.
|
||||
#[test]
|
||||
fn aggregate_budget_accepts_the_limit_and_refuses_one_byte_over() {
|
||||
assert_eq!(budget_total(MAX_TOTAL_BYTES - 16, 0, 16), Some(MAX_TOTAL_BYTES), "equality is allowed");
|
||||
assert_eq!(budget_total(MAX_TOTAL_BYTES - 15, 0, 16), None, "one byte over is refused");
|
||||
assert_eq!(budget_total(MAX_TOTAL_BYTES, 16, 16), Some(MAX_TOTAL_BYTES), "a replaced reservation is released first");
|
||||
}
|
||||
|
||||
/// The callback checks the image against the reservation for the issued geometry before it
|
||||
/// stores anything. An oversized frame is not retained and drain retries it until the
|
||||
/// normal deadline, where it fails once.
|
||||
#[test]
|
||||
fn a_callback_image_over_the_reserved_geometry_is_not_retained() {
|
||||
let now = web_time::Instant::now();
|
||||
let mut cap = Capture::default();
|
||||
let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 };
|
||||
cap.geometry = Some(geometry);
|
||||
cap.settled_passes = SETTLED_PASSES;
|
||||
cap.ui_passes = 10;
|
||||
let (_token, rx) = setup(&mut cap, now, Some("huge.png"));
|
||||
cap.requests[0].issued = Some(Issued { geometry });
|
||||
cap.requests[0].reservation = 16;
|
||||
cap.reserved = 16;
|
||||
let slot = Arc::clone(&cap.requests[0].slot);
|
||||
let oversized = image(3, 3);
|
||||
let weak = Arc::downgrade(&oversized);
|
||||
assert!(slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, oversized.clone()));
|
||||
drop(oversized);
|
||||
assert!(weak.upgrade().is_none(), "the rejected pixels are released before the next pass");
|
||||
assert!(matches!(slot.take(), SlotResult::Retry(_)), "the oversized frame is retryable, not stored");
|
||||
|
||||
// Put the retry result back into the request and retry once without writing a file.
|
||||
let slot = Arc::clone(&cap.requests[0].slot);
|
||||
slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, image(3, 3));
|
||||
let done = cap.drain(now, Some(7));
|
||||
assert!(done.is_empty(), "an oversized image is never written");
|
||||
assert_eq!(cap.pending(), 1, "the request retries against fresh geometry");
|
||||
assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released");
|
||||
assert!(rx.try_recv().is_err(), "no premature error reply");
|
||||
|
||||
let done = cap.drain(now + DEADLINE, Some(7));
|
||||
assert!(done.is_empty());
|
||||
assert_eq!(cap.pending(), 0);
|
||||
assert_eq!(cap.reserved_bytes(), 0);
|
||||
assert_eq!(rx.recv().unwrap()["ok"], false, "the normal deadline still fails the request once");
|
||||
}
|
||||
|
||||
/// A completion wakes the viewport that issued it, once, and only after the slot lock has
|
||||
/// been released. A second clone of the one-shot callback cannot wake it again.
|
||||
#[test]
|
||||
fn a_completion_wakes_only_its_own_viewport_once() {
|
||||
let ctx = Context::default();
|
||||
let viewport = ViewportId::from_hash_of("capture-wake");
|
||||
let slot = Arc::new(CaptureSlot::new(1));
|
||||
let weak = Arc::downgrade(&slot);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
// A new Context starts with pending root paints. Establish the idle state first.
|
||||
for _ in 0..3 {
|
||||
ctx.run_ui(Default::default(), |_| {}).drop_without_applying_deltas();
|
||||
}
|
||||
assert!(!ctx.has_requested_repaint_for(&ViewportId::ROOT));
|
||||
ctx.set_repaint_observer(move |info| {
|
||||
let _ = tx.send(info.viewport_id);
|
||||
});
|
||||
let callback = completion_callback(ctx.clone(), viewport, weak, 1, CaptureBounds { max_image_bytes: 16 });
|
||||
let second = callback.clone();
|
||||
callback.complete(image(2, 2));
|
||||
second.complete(image(2, 2));
|
||||
assert_eq!(rx.try_recv().ok(), Some(viewport), "the issuing viewport is woken");
|
||||
assert!(rx.try_recv().is_err(), "a one-shot completion wakes once");
|
||||
assert!(ctx.has_requested_repaint_for(&viewport));
|
||||
assert!(!ctx.has_requested_repaint_for(&ViewportId::ROOT), "another viewport is not woken");
|
||||
}
|
||||
|
||||
/// A queued request needs a bounded hidden paint to reach issue. Once issued, the screenshot
|
||||
/// command/callback own the wake and only the deadline timer remains, so a hidden window does
|
||||
/// not repaint every 16 ms.
|
||||
#[test]
|
||||
fn hidden_paints_are_bounded_to_queued_requests_and_the_deadline() {
|
||||
let now = web_time::Instant::now();
|
||||
let ctx = Context::default();
|
||||
let mut cap = Capture::default();
|
||||
setup(&mut cap, now, None);
|
||||
|
||||
let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now));
|
||||
let queued = output.viewport_output.get(&ViewportId::ROOT).expect("root output");
|
||||
assert!(
|
||||
queued.commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden)),
|
||||
"a queued hidden request asks for a real bounded paint: {:?}",
|
||||
queued.commands
|
||||
);
|
||||
output.drop_without_applying_deltas();
|
||||
|
||||
let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 };
|
||||
cap.geometry = Some(geometry);
|
||||
cap.requests[0].issued = Some(Issued { geometry });
|
||||
cap.requests[0].reservation = 16;
|
||||
// Drain egui's initial/previous repaint passes before inspecting the future deadline.
|
||||
for _ in 0..3 {
|
||||
let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now));
|
||||
assert!(!output.viewport_output[&ViewportId::ROOT].commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden)));
|
||||
output.drop_without_applying_deltas();
|
||||
}
|
||||
let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now));
|
||||
let issued = output.viewport_output.get(&ViewportId::ROOT).expect("root output");
|
||||
assert!(
|
||||
!issued.commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden)),
|
||||
"an issued screenshot does not force hidden paints: {:?}",
|
||||
issued.commands
|
||||
);
|
||||
assert!(
|
||||
issued.repaint_delay > Duration::from_secs(1) && issued.repaint_delay <= DEADLINE,
|
||||
"only the normal deadline wake remains: {:?}",
|
||||
issued.repaint_delay
|
||||
);
|
||||
output.drop_without_applying_deltas();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -173,8 +174,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 +198,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 +214,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 +222,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 +236,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 +246,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 +347,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,
|
||||
@@ -411,9 +412,34 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option<deckcraft_engine::T
|
||||
|
||||
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 +458,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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
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))
|
||||
@@ -183,8 +183,8 @@ fn restore_preferences(app: &mut SlideApp, d: &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);
|
||||
}
|
||||
@@ -286,7 +286,7 @@ 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));
|
||||
opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, label));
|
||||
}
|
||||
|
||||
const ALBUM_THEMES: &[&str] = &["DeckCraft", "Harbor", "Ember", "Meadow", "Nocturne", "Paper", "Coral Reef", "Slate"];
|
||||
@@ -565,7 +565,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
|
||||
});
|
||||
@@ -613,7 +613,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
|
||||
});
|
||||
@@ -671,7 +671,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
|
||||
});
|
||||
@@ -724,7 +724,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
|
||||
});
|
||||
@@ -959,8 +959,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();
|
||||
}
|
||||
@@ -1054,9 +1054,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());
|
||||
@@ -1065,6 +1065,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));
|
||||
@@ -1183,9 +1184,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());
|
||||
}
|
||||
@@ -1195,6 +1196,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");
|
||||
@@ -1273,7 +1275,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() {
|
||||
@@ -1342,8 +1344,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 {
|
||||
@@ -1352,6 +1354,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() {
|
||||
@@ -1431,7 +1434,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 = [
|
||||
@@ -1447,13 +1451,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);
|
||||
@@ -1517,7 +1522,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");
|
||||
@@ -1529,7 +1534,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 {
|
||||
@@ -1574,8 +1579,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)",
|
||||
@@ -1602,6 +1607,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() {
|
||||
@@ -1658,12 +1664,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| {
|
||||
@@ -1727,7 +1734,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() {
|
||||
@@ -1946,7 +1953,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() {
|
||||
@@ -2020,14 +2030,14 @@ 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();
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("+ Row").clicked() {
|
||||
let n = grid.first().map(|r| r.len()).unwrap_or(2);
|
||||
let n = grid.first().map(Vec::len).unwrap_or(2);
|
||||
grid.push(vec![String::new(); n]);
|
||||
}
|
||||
if ui.button("+ Series").clicked() {
|
||||
@@ -2085,7 +2095,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 {
|
||||
@@ -2126,10 +2136,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();
|
||||
@@ -2173,7 +2183,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, scale as f64, "Interface size"));
|
||||
if r.is_pointer_button_down_on() {
|
||||
if r.changed() || pending.is_some() {
|
||||
d.fields.insert("uiScale".into(), scale.to_string());
|
||||
@@ -2198,8 +2211,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 {
|
||||
@@ -2424,8 +2439,8 @@ fn size_position_value(d: &Dialog) -> Result<Value, String> {
|
||||
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);
|
||||
}
|
||||
@@ -2528,7 +2543,7 @@ 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));
|
||||
opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, lang_label));
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.")
|
||||
@@ -2742,16 +2757,17 @@ 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)]) {
|
||||
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 {
|
||||
@@ -2776,10 +2792,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() {
|
||||
@@ -2787,8 +2804,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);
|
||||
@@ -2802,6 +2819,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")],
|
||||
);
|
||||
@@ -2810,21 +2828,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",
|
||||
@@ -2841,6 +2859,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);
|
||||
@@ -2943,7 +2962,7 @@ fn paragraph_return_params(d: &mut Dialog) -> Value {
|
||||
let line_value = finite_pt(&d.get("lineValue", "1")).unwrap_or(1.0);
|
||||
let before_same = d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "");
|
||||
let after_same = d.get("spaceAfter", "") == d.get("spaceAfterSeed", "");
|
||||
let v = json!({
|
||||
json!({
|
||||
"align": d.get("align", "left"),
|
||||
"indentLeft": before,
|
||||
"indentRight": after,
|
||||
@@ -2957,8 +2976,7 @@ fn paragraph_return_params(d: &mut Dialog) -> Value {
|
||||
"lineValue": line_value,
|
||||
"tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])),
|
||||
"defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)),
|
||||
});
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
fn line_preset_label(kind: &str, value: &str) -> &'static str {
|
||||
@@ -2984,6 +3002,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui,
|
||||
d,
|
||||
"paragraph_align",
|
||||
"Alignment",
|
||||
"align",
|
||||
&[
|
||||
("left", "Left"),
|
||||
@@ -3005,7 +3024,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");
|
||||
@@ -3020,10 +3039,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")),
|
||||
@@ -3036,6 +3055,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);
|
||||
});
|
||||
@@ -3162,9 +3182,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");
|
||||
@@ -3322,14 +3342,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");
|
||||
|
||||
@@ -40,7 +40,7 @@ pub struct State {
|
||||
}
|
||||
|
||||
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).
|
||||
|
||||
@@ -27,7 +27,7 @@ fn harness() -> Harness<'static, SlideApp> {
|
||||
}
|
||||
|
||||
fn menu() -> Id {
|
||||
Id::new("test-menu")
|
||||
Id::unique("test-menu")
|
||||
}
|
||||
|
||||
/// A colour menu asked for the Eyedropper.
|
||||
|
||||
@@ -106,7 +106,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.
|
||||
@@ -167,8 +167,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 {
|
||||
@@ -185,6 +185,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"));
|
||||
@@ -220,15 +221,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)];
|
||||
@@ -303,10 +306,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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+840
-17
@@ -45,7 +45,7 @@ fn korean_composes_in_a_slide_text_box() {
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.run_steps(2);
|
||||
let ime = h.output().platform_output.ime.expect("the input method is on while editing");
|
||||
let ime = h.output().platform_output.ime.as_ref().expect("the input method is on while editing");
|
||||
let slide = h.state().slide_rect.unwrap();
|
||||
assert!(slide.contains(ime.rect.center()), "candidate window at the insertion point {:?}, on the slide {slide:?}", ime.rect);
|
||||
assert!(ime.rect.width() < 8.0 && ime.rect.height() > 4.0, "a caret, not the box: {:?}", ime.rect);
|
||||
@@ -61,7 +61,7 @@ fn korean_composes_in_a_slide_text_box() {
|
||||
assert!(!deckcraft_engine::cmd::text::composition_underlines(st).is_empty(), "underlined while composing");
|
||||
}
|
||||
h.run_steps(1);
|
||||
let ime = h.output().platform_output.ime.unwrap();
|
||||
let ime = h.output().platform_output.ime.as_ref().unwrap();
|
||||
assert!(ime.rect.center().x > caret0.center().x, "the candidate window follows the insertion point");
|
||||
h.event(Event::Ime(ImeEvent::Commit("한글".into())));
|
||||
h.run_steps(2);
|
||||
@@ -94,11 +94,214 @@ fn japanese_conversion_shows_the_active_part() {
|
||||
let u = deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap());
|
||||
assert_eq!(u.iter().filter(|x| x.2).count(), 1, "{u:?}");
|
||||
let undo0 = undo_len(h.state());
|
||||
h.key_press(egui::Key::End);
|
||||
// kittest runs one frame per queued event. Assert the key-down frame itself: the platform
|
||||
// composition must be cancelled there, not one frame later after the key-up releases it.
|
||||
h.key_down(egui::Key::End);
|
||||
h.run_steps(1);
|
||||
let interrupted = h.output().platform_output.ime.clone().expect("still editing");
|
||||
assert!(interrupted.should_interrupt_composition, "the platform composition is interrupted");
|
||||
assert_eq!(title_text(h.state()), "日本ご");
|
||||
assert!(h.state().session.active().unwrap().composition.is_none());
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||
h.key_up(egui::Key::End);
|
||||
h.run_steps(1);
|
||||
// The published interrupt is the platform reset boundary; the tombstone for the settled
|
||||
// generation ends there, so a later commit-only event is treated as a new platform
|
||||
// generation rather than being dropped for the rest of the session.
|
||||
assert!(h.state().ime_cancelled.is_none(), "the published interrupt retired the settled tombstone");
|
||||
}
|
||||
|
||||
/// A composition the application settled (here an End key) drops the platform pre-edit and commit
|
||||
/// already queued in the same input batch, so the conversion is typed once. The published
|
||||
/// interrupt is the explicit boundary: after it the next pre-edit is a new generation. A commit
|
||||
/// the platform queues after that reset is indistinguishable from a legitimate direct commit
|
||||
/// (egui's events carry no generation), so the bounded tombstone does not pretend otherwise.
|
||||
#[test]
|
||||
fn settled_composition_drops_queued_preedit_and_same_batch_commit() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.event(preedit("日本"));
|
||||
h.run_steps(1);
|
||||
let undo0 = undo_len(h.state());
|
||||
|
||||
// One RawInput: the key settles the composition and the platform's queued pre-edit/commit
|
||||
// arrive after it in the same pass. None of them may be applied. Force an extra discarded
|
||||
// pass too: the interrupt may only be consumed by the settled pass the platform sees.
|
||||
h.ctx.request_discard("ime interrupt through a discarded pass");
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||||
h.input_mut().events.push(preedit("日本"));
|
||||
h.input_mut().events.push(Event::Ime(ImeEvent::Commit("日本".into())));
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::End, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "日本", "the stale pre-edit and commit do not restart the cancelled composition");
|
||||
assert!(h.state().session.active().unwrap().composition.is_none());
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1, "the settled composition is one undo step");
|
||||
let interrupted = h.output().platform_output.ime.clone().expect("still editing");
|
||||
assert!(interrupted.should_interrupt_composition, "the settled platform composition is interrupted");
|
||||
assert!(h.state().ime_cancelled.is_none(), "the settled pass published the interrupt and retired the tombstone");
|
||||
|
||||
// A real new composition after the published interrupt is accepted.
|
||||
h.event(preedit("新"));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "日本新", "a legitimate new pre-edit starts a new generation");
|
||||
assert!(h.state().session.active().unwrap().composition.is_some());
|
||||
h.event(Event::Ime(ImeEvent::Commit("新".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "日本新");
|
||||
}
|
||||
|
||||
/// A commit-only event in the settling batch is stale (it belongs to the composition the key
|
||||
/// ended). After the published interrupt, a later commit-only event is a legitimate direct input
|
||||
/// method commit and must not be swallowed by a permanent tombstone.
|
||||
#[test]
|
||||
fn stale_commit_in_the_settling_batch_is_rejected_and_a_later_direct_commit_is_accepted() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.event(preedit("日本"));
|
||||
h.run_steps(1);
|
||||
let undo0 = undo_len(h.state());
|
||||
|
||||
// One RawInput: the key settles the composition and the platform's queued commit follows.
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||||
h.input_mut().events.push(Event::Ime(ImeEvent::Commit("日本".into())));
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::End, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "日本", "the same-batch commit does not type the conversion twice");
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1);
|
||||
assert!(h.state().ime_cancelled.is_none(), "the published interrupt retired the tombstone");
|
||||
|
||||
// A direct commit with no pre-edit (a real IME's punctuation/full-width character path)
|
||||
// arrives after the boundary and is accepted.
|
||||
h.event(Event::Ime(ImeEvent::Commit("。".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "日本。", "a later commit-only is not lost");
|
||||
}
|
||||
|
||||
/// The interrupt is only a boundary when it reaches the platform. A discarded layout/font pass
|
||||
/// keeps both the pending interrupt and the tombstone; the next settled pass retires the tombstone
|
||||
/// and publishes the reset. Assert the per-pass state, not just the final output carrier.
|
||||
#[test]
|
||||
fn a_discarded_pass_keeps_the_tombstone_until_a_settled_pass_publishes_the_interrupt() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let traces = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = traces.clone();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(
|
||||
move |ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
seen.lock().unwrap().push((
|
||||
app.ime_cancelled.is_some(),
|
||||
app.ime_interrupt_pending,
|
||||
app.ime_composition.is_none(),
|
||||
ui.ctx().will_discard(),
|
||||
));
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
h.run_steps(3);
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.event(preedit("日本"));
|
||||
h.run_steps(1);
|
||||
traces.lock().unwrap().clear();
|
||||
|
||||
h.ctx.request_discard("discarded IME interrupt boundary");
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||||
h.input_mut().events.push(preedit("日本"));
|
||||
h.input_mut().events.push(Event::Ime(ImeEvent::Commit("日本".into())));
|
||||
h.run_steps(1);
|
||||
|
||||
let t = traces.lock().unwrap().clone();
|
||||
assert_eq!(t.len(), 2, "one discarded pass followed by the settled pass: {t:?}");
|
||||
assert!(t[0].0, "the discarded pass keeps the settled tombstone");
|
||||
assert!(t[0].1, "the interrupt stays pending through the discarded pass");
|
||||
assert!(t[0].2, "the stale pre-edit/commit cleared the tracked composition");
|
||||
assert!(t[0].3, "the first pass is discarded");
|
||||
assert!(!t[1].0, "the settled pass publishes the interrupt and retires the tombstone");
|
||||
assert!(!t[1].1);
|
||||
assert!(!t[1].3);
|
||||
assert_eq!(title_text(h.state()), "日本");
|
||||
assert!(h.output().platform_output.ime.as_ref().is_some_and(|ime| ime.should_interrupt_composition));
|
||||
}
|
||||
|
||||
/// A settled composition whose descriptor was not painted leaves a tombstone. A *discarded*
|
||||
/// pass keeps it (the platform has not been told to reset), but an accepted no-descriptor pass is
|
||||
/// the backend's reset boundary: the tombstone must be retired there, or the first legitimate
|
||||
/// commit into the next text target is lost.
|
||||
#[test]
|
||||
fn accepted_no_descriptor_pass_retires_the_settled_tombstone() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let mut app = SlideApp::new(Session::with_new(), Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let title = app.session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
app.run("text.edit", json!({"id": title})).unwrap();
|
||||
let owner = app.ime_target().expect("the edited title is an IME target").owner;
|
||||
let generation = 7;
|
||||
app.ime_generation = generation;
|
||||
app.ime_composition = Some(crate::ImeComposition { owner, generation, settled: true });
|
||||
app.ime_cancelled = Some(crate::ImeCancelled { owner, generation });
|
||||
app.ime_interrupt_pending = true;
|
||||
app.ime_pending = None;
|
||||
|
||||
let traces = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = traces.clone();
|
||||
ctx.request_discard("no-descriptor IME boundary");
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let will_discard = ui.ctx().will_discard();
|
||||
app.ime_pending = None;
|
||||
app.observe_ime(ui.ctx());
|
||||
seen.lock().unwrap().push((will_discard, app.ime_cancelled.is_some(), app.ime_interrupt_pending));
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
|
||||
let t = traces.lock().unwrap().clone();
|
||||
assert_eq!(t.len(), 2, "one discarded pass then the accepted pass: {t:?}");
|
||||
assert!(t[0].0, "the first pass is discarded");
|
||||
assert!(t[0].1, "a discarded pass keeps the tombstone");
|
||||
assert!(t[0].2, "a discarded pass keeps the interrupt pending");
|
||||
assert!(!t[1].0, "the second pass is accepted");
|
||||
assert!(!t[1].1, "the accepted no-descriptor pass retires the tombstone");
|
||||
assert!(!t[1].2, "there is no descriptor to publish the interrupt in");
|
||||
assert!(app.ime_composition.is_none(), "the settled composition is retired with its tombstone");
|
||||
|
||||
// The next text target's first event is a legitimate direct commit, not the old generation.
|
||||
app.ime_event(&ctx, &egui::ImeEvent::Commit("。".into()));
|
||||
assert_eq!(title_text(&app), "。", "a first commit in the new target is not swallowed");
|
||||
}
|
||||
|
||||
/// A focused stock `TextEdit` can publish its own `o.ime` while the canvas has no pending rect.
|
||||
/// That descriptor is not a backend reset: the canvas tombstone and settled composition must
|
||||
/// survive until an accepted pass really publishes no descriptor at all.
|
||||
#[test]
|
||||
fn focused_text_field_descriptor_keeps_the_settled_tombstone() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let notes = egui::Id::unique("notes_edit");
|
||||
h.ctx.memory_mut(|m| m.request_focus(notes));
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.memory(|m| m.has_focus(notes)), "the notes TextEdit has focus");
|
||||
let owner = egui::Id::unique("old-canvas-ime");
|
||||
h.state_mut().ime_composition = Some(crate::ImeComposition { owner, generation: 7, settled: true });
|
||||
h.state_mut().ime_cancelled = Some(crate::ImeCancelled { owner, generation: 7 });
|
||||
h.state_mut().ime_interrupt_pending = true;
|
||||
|
||||
h.run_steps(1);
|
||||
assert!(h.output().platform_output.ime.is_some(), "the focused stock TextEdit publishes its own descriptor");
|
||||
assert!(h.state().ime_cancelled.is_some(), "a descriptor-publishing pass is not a reset");
|
||||
assert!(h.state().ime_interrupt_pending, "the canvas interrupt is still waiting for a reset pass");
|
||||
assert!(h.state().ime_composition.is_some_and(|c| c.settled), "the settled composition is not retired");
|
||||
|
||||
// With focus gone there is no canvas target and no TextEdit descriptor: this accepted pass
|
||||
// is the backend reset boundary, so the tombstone and settled composition retire.
|
||||
h.ctx.memory_mut(|m| m.surrender_focus(notes));
|
||||
h.run_steps(1);
|
||||
assert!(h.output().platform_output.ime.is_none(), "nothing publishes an IME descriptor");
|
||||
assert!(h.state().ime_cancelled.is_none(), "the accepted no-descriptor pass retires the tombstone");
|
||||
assert!(!h.state().ime_interrupt_pending);
|
||||
assert!(h.state().ime_composition.is_none(), "the settled composition retires with its tombstone");
|
||||
}
|
||||
|
||||
/// Typing on a selected (not edited) shape with an input method replaces its text, as typing does;
|
||||
@@ -124,7 +327,7 @@ fn composing_on_a_selected_shape_starts_editing_it() {
|
||||
#[test]
|
||||
fn korean_composes_in_the_notes_pane() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let id = egui::Id::unique("notes_edit");
|
||||
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.memory(|m| m.has_focus(id)));
|
||||
@@ -134,7 +337,7 @@ fn korean_composes_in_the_notes_pane() {
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().notes_buf.2.text, pre, "the preedit replaces the last one");
|
||||
}
|
||||
let ime = h.output().platform_output.ime.expect("the notes field turns the input method on");
|
||||
let ime = h.output().platform_output.ime.as_ref().expect("the notes field turns the input method on");
|
||||
let slide = h.state().slide_rect.unwrap();
|
||||
assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide");
|
||||
h.event(Event::Ime(ImeEvent::Commit("노트".into())));
|
||||
@@ -183,18 +386,69 @@ fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() {
|
||||
let korean = "한국어 발표 자료";
|
||||
let japanese = "日本語のスライド";
|
||||
let chinese = "简体中文演示文稿";
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.set", &json!({"id": title, "text": japanese})).unwrap();
|
||||
s.execute("slide.notes", &json!({"text": "발표자 노트입니다"})).unwrap();
|
||||
s.execute("section.add", &json!({"name": chinese})).unwrap();
|
||||
s.doc_mut().unwrap().path = Some(format!("{korean}.pptx"));
|
||||
let notes = "발표자 노트입니다";
|
||||
let require_all = option_env!("CRAFT_FONTS_REQUIRED").is_some() || deckcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "LXGW WenKai Lite");
|
||||
// The title slide's title and subtitle are painted by the initial Normal view, under
|
||||
// kittest's strict missing-glyph policy. Pre-paint CJK only when this build is required to
|
||||
// carry it or this machine can draw every initial sample; an optional build without those
|
||||
// fonts must skip before that first paint instead of panicking and only then checking.
|
||||
let initial_cjk = require_all || [chinese, japanese, korean].iter().all(|t| system_has(t));
|
||||
let slide = s.doc().unwrap().current_slide().unwrap();
|
||||
let title = slide.shapes[0].id.0;
|
||||
let body = slide.shapes[1].id.0;
|
||||
if initial_cjk {
|
||||
s.execute("text.set", &json!({"id": title, "text": chinese})).unwrap();
|
||||
s.execute("text.set", &json!({"id": body, "text": japanese})).unwrap();
|
||||
s.execute("slide.notes", &json!({"text": notes})).unwrap();
|
||||
s.execute("section.add", &json!({"name": chinese})).unwrap();
|
||||
s.doc_mut().unwrap().path = Some(format!("{korean}.pptx"));
|
||||
}
|
||||
let mut h = harness(s);
|
||||
if initial_cjk {
|
||||
// `harness` already completed its first passes. Both initially painted samples must have
|
||||
// real glyph coverage before any command (and the strict policy would have panicked
|
||||
// otherwise).
|
||||
let initial: Vec<String> = h.state().session.active().unwrap().doc.slides[0]
|
||||
.shapes
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|sh| sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::<Vec<_>>().join("\n")).unwrap_or_default())
|
||||
.collect();
|
||||
assert_eq!(initial, [chinese.to_owned(), japanese.to_owned()]);
|
||||
for text in [chinese, japanese] {
|
||||
for family in [FontId::proportional(13.0), crate::theme::bold(13.0)] {
|
||||
let missing: String = text.chars().filter(|c| !c.is_whitespace() && !h.ctx.fonts_mut(|f| f.has_glyph(&family, *c))).collect();
|
||||
assert!(missing.is_empty(), "{family:?} draws {missing:?} of initially painted {text:?} as boxes");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("optional font input: initial CJK paint skipped before layout (no complete local coverage)");
|
||||
// Each sample this machine can draw is still painted for real after the check; the
|
||||
// strict policy then checks it. Unsupported samples stay out of the document entirely.
|
||||
if system_has(chinese) {
|
||||
h.state_mut().run("text.set", json!({"id": title, "text": chinese})).unwrap();
|
||||
h.state_mut().run("section.add", json!({"name": chinese})).unwrap();
|
||||
}
|
||||
if system_has(japanese) {
|
||||
h.state_mut().run("text.set", json!({"id": body, "text": japanese})).unwrap();
|
||||
}
|
||||
if system_has(korean) {
|
||||
h.state_mut().run("slide.notes", json!({"text": notes})).unwrap();
|
||||
h.state_mut().session.doc_mut().unwrap().path = Some(format!("{korean}.pptx"));
|
||||
}
|
||||
if system_has(chinese) || system_has(japanese) || system_has(korean) {
|
||||
h.run_steps(3);
|
||||
}
|
||||
}
|
||||
// Outline and file-name text is painted only after this command; the Chinese title above has
|
||||
// already covered the initial-paint case.
|
||||
h.state_mut().run("view.outline", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.active().unwrap().title().contains(korean));
|
||||
assert!(!initial_cjk || h.state().session.active().unwrap().title().contains(korean));
|
||||
let mut checked = 0;
|
||||
for text in [korean, japanese, chinese, "발표자 노트입니다"] {
|
||||
for text in [korean, japanese, chinese, notes] {
|
||||
if !system_has(text) {
|
||||
assert!(!require_all, "required font input does not draw {text:?}");
|
||||
eprintln!("skipped {text:?}: no font on this machine draws it");
|
||||
continue;
|
||||
}
|
||||
@@ -204,14 +458,583 @@ fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() {
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
if cfg!(windows) || cfg!(target_os = "macos") {
|
||||
assert_eq!(checked, 4, "Windows and macOS have Korean and Chinese fonts");
|
||||
if require_all || cfg!(windows) || cfg!(target_os = "macos") {
|
||||
assert_eq!(checked, 4, "all CJK samples must be exercised with required/bundled fonts");
|
||||
}
|
||||
// Text typed later (a dialog field, a new comment) is looked at too.
|
||||
// Text typed later (a dialog field, a new comment) is looked at too. Never type text this
|
||||
// machine cannot draw: the strict missing-glyph policy would panic before the optional check.
|
||||
let typed = "ไทย";
|
||||
h.event(Event::Text(typed.into()));
|
||||
h.run_steps(2);
|
||||
if system_has(typed) {
|
||||
h.event(Event::Text(typed.into()));
|
||||
h.run_steps(2);
|
||||
assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), typed)));
|
||||
} else if require_all {
|
||||
panic!("required font input does not draw {typed:?}");
|
||||
} else {
|
||||
eprintln!("skipped {typed:?}: no font on this machine draws it");
|
||||
}
|
||||
}
|
||||
|
||||
/// An input method can ask for characters (stock `DeleteSurrounding`) or UTF-8 bytes (the fork's
|
||||
/// `DeleteSurroundingBytes`) around the caret to be removed. The byte range never splits a
|
||||
/// character, and a deletion made while composing stays in the composition's one undo step.
|
||||
#[test]
|
||||
fn ime_delete_surrounding_handles_chars_bytes_and_composition_undo() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.state_mut().session.execute("text.insert", &json!({"text": "abc"})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.event(Event::Ime(ImeEvent::DeleteSurrounding { before_chars: 2, after_chars: 0 }));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "a");
|
||||
let deleted = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!(deleted.change_cause, egui::output::ImeChangeCause::InputMethod, "an accepted deletion is an IME mutation");
|
||||
h.state_mut().session.execute("edit.undo", &json!({})).unwrap();
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abc", "a non-composing deletion is one undo step");
|
||||
|
||||
// UTF-8 bytes: the four bytes of an emoji are one character and must delete whole.
|
||||
h.state_mut().session.execute("text.set", &json!({"id": title, "text": "aé😀"})).unwrap();
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.state_mut().session.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.event(Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 4, after_bytes: 0 }));
|
||||
h.run_steps(2);
|
||||
assert_eq!(title_text(h.state()), "aé", "the emoji's bytes delete exactly one scalar");
|
||||
|
||||
// Deleting before a composition, then committing, is a single undo step.
|
||||
h.state_mut().session.execute("text.set", &json!({"id": title, "text": "abc"})).unwrap();
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.run_steps(2);
|
||||
let undo0 = undo_len(h.state());
|
||||
h.event(preedit("日本"));
|
||||
h.run_steps(1);
|
||||
h.event(Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "ab日本");
|
||||
h.event(Event::Ime(ImeEvent::Commit("日本".into())));
|
||||
h.run_steps(2);
|
||||
assert_eq!(title_text(h.state()), "ab日本");
|
||||
assert_eq!(undo_len(h.state()), undo0 + 1, "delete + commit is one composition step");
|
||||
h.state_mut().session.execute("edit.undo", &json!({})).unwrap();
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abc");
|
||||
}
|
||||
|
||||
/// The canvas producer publishes bounded logical committed text with UTF-8 byte offsets and a
|
||||
/// stable owner/generation across ordinary typing, caret movement and reversed selection.
|
||||
#[test]
|
||||
fn ime_output_is_logical_bounded_and_owner_stable() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.state_mut().session.execute("text.insert", &json!({"text": "aé😀b"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let first = h.output().platform_output.ime.clone().expect("editing text owns the input method");
|
||||
assert_eq!(first.purpose, egui::IMEPurpose::Normal);
|
||||
assert_eq!(first.change_cause, egui::output::ImeChangeCause::Other);
|
||||
let payload = first.surrounding_text.available().expect("model text is available");
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("aé😀b", 8, 8));
|
||||
let (owner, generation) = (first.owner, first.context_generation);
|
||||
|
||||
// A caret move is not a new context and still reports logical UTF-8 offsets.
|
||||
h.state_mut().session.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap();
|
||||
h.run_steps(1);
|
||||
let moved = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!((moved.owner, moved.context_generation), (owner, generation));
|
||||
let payload = moved.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.cursor, payload.anchor), (3, 3));
|
||||
|
||||
// A reversed selection keeps caret < anchor in the outgoing bytes.
|
||||
h.state_mut().session.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap();
|
||||
h.run_steps(1);
|
||||
let reversed = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!((reversed.owner, reversed.context_generation), (owner, generation));
|
||||
let payload = reversed.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.cursor, payload.anchor), (1, 7), "caret and anchor keep their direction");
|
||||
|
||||
// A forced extra pass in the same frame keeps the same context.
|
||||
h.ctx.request_discard("ime multipass regression");
|
||||
h.run_steps(1);
|
||||
let multipass = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!((multipass.owner, multipass.context_generation), (owner, generation));
|
||||
|
||||
// The extra window renders the slide and owns no text: whether the backend draws it as its
|
||||
// own native viewport or (as in kittest, which installs no immediate-viewport renderer)
|
||||
// embedded in this pass, it must neither own nor disturb the editor's context.
|
||||
h.state_mut().run("view.newWindow", json!({"on": true})).unwrap();
|
||||
h.run_steps(2);
|
||||
let second_window = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!((second_window.owner, second_window.context_generation), (owner, generation));
|
||||
h.state_mut().run("view.newWindow", json!({"on": false})).unwrap();
|
||||
}
|
||||
|
||||
/// A model-stored composition is pre-edit, not committed text. An accepted commit changes the
|
||||
/// committed snapshot and is the only path that earns `InputMethod`.
|
||||
#[test]
|
||||
fn preedit_is_excluded_and_a_commit_is_input_method() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.state_mut().session.execute("text.insert", &json!({"text": "ab"})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.event(preedit("日本"));
|
||||
h.run_steps(1);
|
||||
let composing = h.output().platform_output.ime.clone().unwrap();
|
||||
let payload = composing.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("ab", 2, 2), "pre-edit is not committed text");
|
||||
assert_eq!(composing.change_cause, egui::output::ImeChangeCause::Other);
|
||||
|
||||
h.event(Event::Ime(ImeEvent::Commit("日本".into())));
|
||||
h.run_steps(1);
|
||||
let committed = h.output().platform_output.ime.clone().unwrap();
|
||||
let payload = committed.surrounding_text.available().unwrap();
|
||||
assert_eq!(payload.text, "ab日本");
|
||||
assert_eq!((payload.cursor, payload.anchor), (8, 8));
|
||||
assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod);
|
||||
|
||||
// A pre-edit and its commit in one frame is still one accepted IME mutation. `Harness::event`
|
||||
// queues one frame per event, so put both in a single RawInput.
|
||||
h.state_mut().session.execute("text.insert", &json!({"text": "X"})).unwrap();
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(preedit("Y"));
|
||||
h.input_mut().events.push(Event::Ime(ImeEvent::Commit("Y".into())));
|
||||
h.run_steps(1);
|
||||
let same_frame = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!(title_text(h.state()), "ab日本XY");
|
||||
assert_eq!(same_frame.change_cause, egui::output::ImeChangeCause::InputMethod);
|
||||
}
|
||||
|
||||
/// An application edit anywhere in the frame dominates an IME commit in that same frame.
|
||||
#[test]
|
||||
fn mixed_application_and_ime_edits_report_other() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.state_mut().session.execute("text.insert", &json!({"text": "a"})).unwrap();
|
||||
h.run_steps(2);
|
||||
// Both events belong to one pass. `Harness::event` queues one frame per event, so put them
|
||||
// in a single RawInput instead.
|
||||
h.input_mut().events.push(Event::Text("x".into()));
|
||||
h.input_mut().events.push(Event::Ime(ImeEvent::Commit("y".into())));
|
||||
h.run_steps(1);
|
||||
let ime = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!(title_text(h.state()), "axy");
|
||||
assert_eq!(ime.change_cause, egui::output::ImeChangeCause::Other);
|
||||
let payload = ime.surrounding_text.available().unwrap();
|
||||
assert_eq!(payload.text, "axy");
|
||||
}
|
||||
|
||||
/// An empty commit is a no-op: it must not claim an IME mutation. An empty body is still an
|
||||
/// available surrounding context, not `Unavailable`.
|
||||
#[test]
|
||||
fn no_op_commit_stays_other_and_empty_text_is_available() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.run_steps(1);
|
||||
let empty = h.output().platform_output.ime.clone().unwrap();
|
||||
let payload = empty.surrounding_text.available().expect("an empty editable body is available");
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("", 0, 0));
|
||||
|
||||
h.event(Event::Ime(ImeEvent::Commit(String::new())));
|
||||
h.run_steps(1);
|
||||
let no_op = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!(no_op.change_cause, egui::output::ImeChangeCause::Other);
|
||||
assert!(no_op.surrounding_text.available().is_some_and(|p| p.text.is_empty()));
|
||||
}
|
||||
|
||||
/// A no-op delete is not a mutation: a real commit in the same `RawInput` is still an accepted
|
||||
/// IME edit, not `Other` caused by a poisoned frame.
|
||||
#[test]
|
||||
fn no_op_delete_does_not_poison_a_commit_in_the_same_frame() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.state_mut().session.execute("text.insert", &json!({"text": "abc"})).unwrap();
|
||||
h.run_steps(2);
|
||||
|
||||
h.input_mut().events.push(Event::Ime(ImeEvent::DeleteSurrounding { before_chars: 0, after_chars: 0 }));
|
||||
h.input_mut().events.push(Event::Ime(ImeEvent::Commit("X".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abcX");
|
||||
let ime = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod, "the no-op did not poison the real commit");
|
||||
}
|
||||
|
||||
/// An empty commit is likewise a no-op; a real commit later in the same frame remains the
|
||||
/// accepted input-method mutation.
|
||||
#[test]
|
||||
fn empty_commit_does_not_poison_a_commit_in_the_same_frame() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.state_mut().session.execute("text.insert", &json!({"text": "ab"})).unwrap();
|
||||
h.run_steps(2);
|
||||
|
||||
h.input_mut().events.push(Event::Ime(ImeEvent::Commit(String::new())));
|
||||
h.input_mut().events.push(Event::Ime(ImeEvent::Commit("Y".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abY");
|
||||
let ime = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod, "the empty commit did not poison the real commit");
|
||||
}
|
||||
|
||||
/// A selection wider than the surrounding-text budget is published as `Unavailable`. A real
|
||||
/// deletion from that state must still be certified through the unavailable revision, including
|
||||
/// when the selection stays unavailable afterwards.
|
||||
#[test]
|
||||
fn wide_selection_delete_is_certified_with_the_unavailable_revision() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.state_mut().session.execute("text.set", &json!({"id": title, "text": "a".repeat(4500)})).unwrap();
|
||||
h.state_mut().session.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 4500]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let before = h.output().platform_output.ime.clone().unwrap();
|
||||
assert!(before.surrounding_text.available().is_none(), "the wide selection is unavailable");
|
||||
|
||||
h.input_mut().events.push(Event::Ime(ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 0 }));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()).len(), 4499, "one character before the selection was deleted");
|
||||
let after = h.output().platform_output.ime.clone().unwrap();
|
||||
assert!(after.surrounding_text.available().is_none(), "the selection is still wider than the budget");
|
||||
assert_eq!(after.change_cause, egui::output::ImeChangeCause::InputMethod, "the unavailable revision certifies the deletion");
|
||||
}
|
||||
|
||||
/// A commit that replaces a wide (unavailable) selection produces available text; the mixed
|
||||
/// unavailable -> available transition needs the before revision to be certified.
|
||||
#[test]
|
||||
fn wide_selection_commit_is_certified_with_the_unavailable_revision() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.state_mut().session.execute("text.set", &json!({"id": title, "text": "a".repeat(4500)})).unwrap();
|
||||
h.state_mut().session.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 4500]})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.output().platform_output.ime.as_ref().unwrap().surrounding_text.available().is_none());
|
||||
|
||||
h.event(Event::Ime(ImeEvent::Commit("Y".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "aY", "the selected tail is replaced");
|
||||
let ime = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!(ime.surrounding_text.available().unwrap().text, "aY");
|
||||
assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod, "the unavailable revision certifies the commit");
|
||||
}
|
||||
|
||||
/// The surrounding window is bounded even in a large body, and switching the edited target
|
||||
/// starts a new owner/generation once.
|
||||
#[test]
|
||||
fn surrounding_text_is_bounded_and_target_replacement_resets_generation() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
let long = "a".repeat(4500);
|
||||
h.state_mut().session.execute("text.set", &json!({"id": title, "text": long})).unwrap();
|
||||
h.state_mut().session.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let ime = h.output().platform_output.ime.clone().unwrap();
|
||||
let payload = ime.surrounding_text.available().unwrap();
|
||||
assert!(payload.text.len() <= 4000, "{}", payload.text.len());
|
||||
assert_eq!(payload.cursor, payload.text.len());
|
||||
let first = (ime.owner, ime.context_generation);
|
||||
|
||||
// A selection wider than the protocol bound is explicitly unavailable, not truncated.
|
||||
h.state_mut().session.execute("text.selectAll", &json!({})).unwrap();
|
||||
h.run_steps(1);
|
||||
let wide = h.output().platform_output.ime.clone().unwrap();
|
||||
assert!(wide.surrounding_text.available().is_none(), "oversized selection is Unavailable");
|
||||
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [20, 20, 200, 80], "text": "other"})).unwrap();
|
||||
let other = r["id"].as_u64().unwrap();
|
||||
h.state_mut().run("text.edit", json!({"id": other})).unwrap();
|
||||
h.run_steps(2);
|
||||
let ime = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_ne!((ime.owner, ime.context_generation), first, "a new target is a new context");
|
||||
let payload = ime.surrounding_text.available().unwrap();
|
||||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("other", 5, 5));
|
||||
}
|
||||
|
||||
/// With no target, the custom owner is hidden: it observes `None` and publishes no descriptor.
|
||||
#[test]
|
||||
fn hidden_canvas_owner_observes_none() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.run_steps(1);
|
||||
let first = h.output().platform_output.ime.clone().unwrap();
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
h.state_mut().run("edit.select", json!({"ids": []})).unwrap();
|
||||
h.run_steps(1);
|
||||
assert!(h.output().platform_output.ime.is_none(), "nothing to type into: no custom owner");
|
||||
|
||||
// Re-focusing the same shape starts one new generation after the hidden pass.
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.run_steps(1);
|
||||
let again = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!(again.owner, first.owner);
|
||||
assert_ne!(again.context_generation, first.context_generation);
|
||||
}
|
||||
|
||||
/// A platform pre-edit or commit queued in the same pass as the application settling the
|
||||
/// composition (a plain click on another shape) must not be typed into the new target: the
|
||||
/// settled text stays in the body it was composed in, and the platform's copy is interrupted.
|
||||
/// Once that interrupt is published, an event the OS still queues is indistinguishable from a
|
||||
/// legitimate new composition (egui's event payload has no generation), so only the settling
|
||||
/// batch can be rejected deterministically.
|
||||
///
|
||||
/// The pre-edit is plain ASCII on purpose: borrowing a CJK face installs fonts and discards the
|
||||
/// pass, which would hide the one pass the interruption is published in.
|
||||
#[test]
|
||||
fn a_settling_batch_cannot_land_an_ime_event_in_the_next_target() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let a = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [400, 400, 240, 120], "text": "Confidential draft"})).unwrap();
|
||||
let b = r["id"].as_u64().unwrap() as u32;
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
h.state_mut().session.execute("text.set", &json!({"id": a, "text": "Quarterly results"})).unwrap();
|
||||
h.state_mut().run("text.edit", json!({"id": a})).unwrap();
|
||||
h.run_steps(2);
|
||||
|
||||
// An input-method pre-edit in A.
|
||||
h.event(preedit("nihon"));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.active().unwrap().composition.is_some());
|
||||
|
||||
// A plain press on B settles the composition (PowerPoint does the same) and selects B. Its
|
||||
// queued pre-edit and commit travel in the same input batch as the settling pass.
|
||||
let press = deckcraft_engine::tools::PointerEvent { kind: deckcraft_engine::tools::PointerKind::Down, x: 500.0, y: 450.0, ..Default::default() };
|
||||
h.state_mut().session.pointer(press).unwrap();
|
||||
h.input_mut().events.push(preedit("nihon"));
|
||||
h.input_mut().events.push(Event::Ime(ImeEvent::Commit("nihon".into())));
|
||||
h.run_steps(1);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(st.composition.is_none(), "the click settles the composition");
|
||||
assert_eq!(st.selection.shapes, vec![deckcraft_model::ShapeId(b)], "B is the new target");
|
||||
assert!(st.selection.text.is_none(), "the queued IME events did not start editing B");
|
||||
assert_eq!(text_of(&h, a), "Quarterly resultsnihon", "the settled pre-edit stays in A");
|
||||
assert_eq!(text_of(&h, b), "Confidential draft", "B's whole text is not replaced");
|
||||
let ime = h.output().platform_output.ime.clone().expect("B is a typing target");
|
||||
assert!(ime.should_interrupt_composition, "the platform's copy is cancelled, not committed back");
|
||||
assert!(h.state().ime_cancelled.is_none(), "the settled pass published the interrupt boundary");
|
||||
}
|
||||
|
||||
/// The extra document window is a rendered image, not a second text target: the canvas owner
|
||||
/// stays in the editing viewport, and closing the last presentation really releases it.
|
||||
#[test]
|
||||
fn a_second_window_keeps_the_canvas_owner_and_closing_the_last_document_tears_it_down() {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.run_steps(1);
|
||||
let first = h.output().platform_output.ime.clone().unwrap();
|
||||
assert_eq!(h.state().ime_viewport, Some(egui::ViewportId::ROOT), "the canvas owner lives in the editing viewport");
|
||||
|
||||
h.state_mut().run("view.newWindow", json!({"on": true})).unwrap();
|
||||
h.run_steps(2);
|
||||
let second = h.output().platform_output.ime.clone().expect("the canvas still owns the input method");
|
||||
assert_eq!(
|
||||
(second.owner, second.context_generation),
|
||||
(first.owner, first.context_generation),
|
||||
"the render-only window neither owns nor disturbs the input method"
|
||||
);
|
||||
assert_eq!(h.state().ime_viewport, Some(egui::ViewportId::ROOT));
|
||||
h.state_mut().run("view.newWindow", json!({"on": false})).unwrap();
|
||||
h.run_steps(1);
|
||||
|
||||
h.state_mut().run("file.close", json!({})).unwrap();
|
||||
h.run_steps(1);
|
||||
assert!(h.state().session.active().is_none(), "the last presentation closed");
|
||||
assert_eq!(h.state().ime_viewport, None, "its viewport's IME state is released");
|
||||
assert!(h.output().platform_output.ime.is_none());
|
||||
}
|
||||
|
||||
/// The text of the shape `id` in the active presentation.
|
||||
fn text_of(h: &egui_kittest::Harness<'static, SlideApp>, id: u32) -> String {
|
||||
h.state()
|
||||
.session
|
||||
.active()
|
||||
.and_then(|d| d.shape(deckcraft_model::ShapeId(id)))
|
||||
.and_then(|sh| sh.text.as_ref())
|
||||
.map(deckcraft_model::text::TextBody::text)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn pending_terminal_editor() -> (egui_kittest::Harness<'static, SlideApp>, egui::Pos2) {
|
||||
let mut h = harness(Session::with_new());
|
||||
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": title})).unwrap();
|
||||
h.event(Event::Text("abcd".into()));
|
||||
h.run_steps(1);
|
||||
h.state_mut().run("text.select", json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap();
|
||||
h.run_steps(1);
|
||||
let point = h.output().platform_output.ime.as_ref().expect("the actual canvas caret1").cursor_rect.center();
|
||||
assert!(h.state().canvas_rect.unwrap().contains(point));
|
||||
h.state_mut().run("text.select", json!({"anchor": [0, 4], "caret": [0, 4]})).unwrap();
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abcd");
|
||||
(h, point)
|
||||
}
|
||||
|
||||
fn clear_pending_preedit(h: &mut egui_kittest::Harness<'_, SlideApp>) {
|
||||
let undo = undo_len(h.state());
|
||||
h.event(preedit("Q"));
|
||||
h.run_steps(1);
|
||||
assert!(h.state().session.active().unwrap().composition.is_some());
|
||||
h.event(preedit(""));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abcd", "the visible preedit was restored, not committed");
|
||||
assert!(h.state().session.active().unwrap().composition.is_none());
|
||||
assert_eq!(undo_len(h.state()), undo);
|
||||
assert!(!h.output().platform_output.ime.as_ref().unwrap().should_interrupt_composition, "empty preedit is not a platform reset");
|
||||
}
|
||||
|
||||
fn pending_terminal_delete(bytes: bool) -> Event {
|
||||
Event::Ime(if bytes {
|
||||
ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }
|
||||
} else {
|
||||
ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 0 }
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_preedit_pointer_interruption_rejects_later_delete_and_terminal_commit() {
|
||||
for bytes in [false, true] {
|
||||
for split_terminal in [false, true] {
|
||||
let (mut h, point) = pending_terminal_editor();
|
||||
clear_pending_preedit(&mut h);
|
||||
let undo = undo_len(h.state());
|
||||
let doc = h.state().session.active().unwrap().doc.clone();
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(point),
|
||||
Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() },
|
||||
]);
|
||||
h.run_steps(1);
|
||||
let t = h.state().session.active().unwrap().selection.text.as_ref().expect("the real pointer kept text editing");
|
||||
assert_eq!((t.anchor, t.caret), ((0, 1), (0, 1)), "the actual pointer moved the caret");
|
||||
h.input_mut().events.push(Event::PointerButton {
|
||||
pos: point,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
h.run_steps(3); // Publishing an interrupt and idle passes do not deliver the terminal.
|
||||
h.input_mut().events.push(pending_terminal_delete(bytes));
|
||||
if split_terminal {
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abcd", "the separately delivered old deletion is refused");
|
||||
}
|
||||
h.input_mut().events.extend([Event::Ime(ImeEvent::Commit("X".into())), preedit("Y")]);
|
||||
h.run_steps(1);
|
||||
// Old terminal is refused, then the new composition in that same batch is valid.
|
||||
assert_eq!(title_text(h.state()), "aYbcd");
|
||||
assert!(
|
||||
!h.output().platform_output.ime.as_ref().unwrap().should_interrupt_composition,
|
||||
"the observed old terminal must not reset the new composition"
|
||||
);
|
||||
assert_eq!(undo_len(h.state()), undo, "neither stale input nor new preedit creates undo");
|
||||
h.event(Event::Ime(ImeEvent::Commit("Y".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "aYbcd");
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(title_text(h.state()), "abcd", "only the fresh commit needs undo");
|
||||
// Before the new preedit the stale terminal must not have changed the presentation;
|
||||
// the fresh composition's own undo snapshot is exactly the original document.
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &h.state().session.active().unwrap().doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_preedit_key_interruption_rejects_a_terminal_in_a_later_batch() {
|
||||
for bytes in [false, true] {
|
||||
let (mut h, _) = pending_terminal_editor();
|
||||
clear_pending_preedit(&mut h);
|
||||
let undo = undo_len(h.state());
|
||||
h.key_down(egui::Key::ArrowLeft);
|
||||
h.run_steps(1);
|
||||
h.key_up(egui::Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.extend([pending_terminal_delete(bytes), Event::Ime(ImeEvent::Commit("X".into()))]);
|
||||
h.run_steps(1);
|
||||
let t = h.state().session.active().unwrap().selection.text.as_ref().unwrap();
|
||||
assert_eq!((t.anchor, t.caret), ((0, 3), (0, 3)));
|
||||
assert_eq!(title_text(h.state()), "abcd");
|
||||
assert_eq!(undo_len(h.state()), undo);
|
||||
h.event(Event::Ime(ImeEvent::Commit("Y".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abcYd", "the later direct commit is new input");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_preedit_target_replacement_rejects_later_terminal_mutations() {
|
||||
for bytes in [false, true] {
|
||||
let (mut h, _) = pending_terminal_editor();
|
||||
let first = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
let second = h.state().session.active().unwrap().doc.slides[0].shapes[1].id.0;
|
||||
h.state_mut().run("text.edit", json!({"id": second})).unwrap();
|
||||
h.event(Event::Text("WXYZ".into()));
|
||||
h.run_steps(1);
|
||||
h.state_mut().run("text.edit", json!({"id": first, "at": [0, 4]})).unwrap();
|
||||
h.run_steps(1);
|
||||
clear_pending_preedit(&mut h);
|
||||
let undo = undo_len(h.state());
|
||||
let doc = h.state().session.active().unwrap().doc.clone();
|
||||
// Actual production target-selection command, in a separate pass before the terminal.
|
||||
h.state_mut().run("text.edit", json!({"id": second, "at": [0, 1]})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.event(pending_terminal_delete(bytes));
|
||||
h.run_steps(1);
|
||||
h.event(Event::Ime(ImeEvent::Commit("X".into())));
|
||||
h.run_steps(1);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &h.state().session.active().unwrap().doc), "neither old mutation can edit the replacement target");
|
||||
assert_eq!(title_text(h.state()), "abcd");
|
||||
assert_eq!(undo_len(h.state()), undo);
|
||||
let t = h.state().session.active().unwrap().selection.text.as_ref().unwrap();
|
||||
assert_eq!((t.shape.0, t.anchor, t.caret), (second, (0, 1), (0, 1)));
|
||||
h.event(preedit("Y"));
|
||||
h.run_steps(1);
|
||||
h.event(Event::Ime(ImeEvent::Commit("Y".into())));
|
||||
h.run_steps(1);
|
||||
let body = h.state().session.active().unwrap().doc.slides[0].shapes[1].text.as_ref().unwrap();
|
||||
assert_eq!(body.paragraphs[0].text(), "WYXYZ", "a fresh composition uses only the new target");
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.state().session.active().unwrap().doc.slides[0].shapes[1].text.as_ref().unwrap().paragraphs[0].text(), "WXYZ");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_preedit_without_interruption_accepts_both_terminal_delete_forms() {
|
||||
for bytes in [false, true] {
|
||||
let (mut h, _) = pending_terminal_editor();
|
||||
clear_pending_preedit(&mut h);
|
||||
h.event(pending_terminal_delete(bytes));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abc");
|
||||
h.event(Event::Ime(ImeEvent::Commit("X".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abcX", "the accepted deletion's caret shift does not stale its commit");
|
||||
assert!(h.state().ime_terminal_pending.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_preedit_accepts_its_commit_then_direct_and_fresh_compositions() {
|
||||
let (mut h, _) = pending_terminal_editor();
|
||||
clear_pending_preedit(&mut h);
|
||||
h.event(Event::Ime(ImeEvent::Commit("X".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abcdX");
|
||||
assert!(h.state().ime_terminal_pending.is_none());
|
||||
h.event(Event::Ime(ImeEvent::Commit("Y".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abcdXY", "a direct commit has no old owner");
|
||||
h.event(preedit("Z"));
|
||||
h.run_steps(1);
|
||||
h.event(Event::Ime(ImeEvent::Commit("Z".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(title_text(h.state()), "abcdXYZ");
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
+1732
-138
File diff suppressed because it is too large
Load Diff
+34
-25
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
@@ -184,7 +197,7 @@ mod tests {
|
||||
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());
|
||||
|
||||
+51
-41
@@ -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();
|
||||
@@ -327,7 +327,7 @@ 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| {
|
||||
@@ -355,10 +355,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}));
|
||||
}
|
||||
@@ -480,13 +480,13 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
|
||||
None => ("shape.fill".into(), json!({"none": true})),
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
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 = solid_fill_transparency_percent(sh.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}));
|
||||
@@ -550,15 +550,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -622,11 +623,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 {
|
||||
@@ -653,9 +654,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}}));
|
||||
}
|
||||
@@ -695,24 +696,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;
|
||||
@@ -747,23 +749,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}}));
|
||||
}
|
||||
@@ -970,8 +973,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"),
|
||||
@@ -988,6 +991,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",
|
||||
@@ -996,14 +1000,15 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1020,7 +1025,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();
|
||||
@@ -1094,17 +1099,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,
|
||||
@@ -1310,7 +1315,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() {
|
||||
@@ -1335,7 +1340,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));
|
||||
@@ -1367,7 +1372,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() {
|
||||
@@ -1448,11 +1453,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));
|
||||
@@ -1553,9 +1558,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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,21 +66,30 @@ fn tabs_from_paragraph_applies_a_stop_and_returns() {
|
||||
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");
|
||||
|
||||
@@ -15,7 +15,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());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.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);
|
||||
@@ -38,10 +41,27 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
|
||||
+249
-156
@@ -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,99 @@ 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()
|
||||
}
|
||||
|
||||
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 +330,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!({}));
|
||||
@@ -447,18 +508,22 @@ fn slides_group(app: &mut SlideApp, ui: &mut 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| {
|
||||
if ui.button("Add Section").clicked() {
|
||||
run(app, "section.add", json!({}));
|
||||
}
|
||||
if ui.button("Remove All Sections").clicked() {
|
||||
run(app, "section.removeAll", json!({}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -657,7 +722,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;
|
||||
@@ -692,7 +757,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!({}));
|
||||
}
|
||||
@@ -714,7 +779,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!({}));
|
||||
}
|
||||
@@ -949,9 +1014,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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1321,7 +1386,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() };
|
||||
@@ -1349,11 +1414,43 @@ pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> {
|
||||
if resp.clicked() && hover.0 > 0 { Some(hover) } else { None }
|
||||
}
|
||||
|
||||
fn render_icon_categories(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], color: Color32, hover: Color32) {
|
||||
let mut cats: Vec<&str> = Vec::new();
|
||||
for icon in icons {
|
||||
if !cats.contains(&icon.category) {
|
||||
cats.push(icon.category);
|
||||
}
|
||||
}
|
||||
for cat in cats {
|
||||
ui.label(egui::RichText::new(cat).small().strong());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for icon in icons.iter().filter(|i| i.category == cat) {
|
||||
icon_cell(app, ui, icon, color, hover);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], q: &str, color: Color32, hover: Color32) {
|
||||
let mut n = 0;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for icon in icons {
|
||||
if icon.name.contains(q) || icon.label.to_lowercase().contains(q) {
|
||||
icon_cell(app, ui, icon, color, hover);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
if n == 0 {
|
||||
ui.label("No icons match.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Lucide stroke icons (Insert › Icons). Search filters the gallery; a click inserts the outline.
|
||||
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();
|
||||
@@ -1361,33 +1458,9 @@ fn icon_library(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let icons = deckcraft_engine::icons::icons();
|
||||
egui::ScrollArea::vertical().id_salt("icon-scroll").max_height(420.0).show(ui, |ui| {
|
||||
if q.is_empty() {
|
||||
let mut cats: Vec<&str> = Vec::new();
|
||||
for icon in icons {
|
||||
if !cats.contains(&icon.category) {
|
||||
cats.push(icon.category);
|
||||
}
|
||||
}
|
||||
for cat in cats {
|
||||
ui.label(egui::RichText::new(cat).small().strong());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for icon in icons.iter().filter(|i| i.category == cat) {
|
||||
icon_cell(app, ui, icon, color, hover);
|
||||
}
|
||||
});
|
||||
}
|
||||
render_icon_categories(app, ui, icons, color, hover);
|
||||
} else {
|
||||
let mut n = 0;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for icon in icons {
|
||||
if icon.name.contains(&q) || icon.label.to_lowercase().contains(&q) {
|
||||
icon_cell(app, ui, icon, color, hover);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
if n == 0 {
|
||||
ui.label("No icons match.");
|
||||
}
|
||||
render_filtered_icons(app, ui, icons, &q, color, hover);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1537,7 +1610,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| {
|
||||
@@ -1564,7 +1637,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() {
|
||||
@@ -1578,7 +1651,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() {
|
||||
@@ -1718,14 +1791,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}));
|
||||
@@ -1754,6 +1827,7 @@ 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| {
|
||||
@@ -2472,17 +2546,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)}));
|
||||
}
|
||||
@@ -2492,9 +2566,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))}));
|
||||
}
|
||||
@@ -2548,17 +2622,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!({}));
|
||||
@@ -2681,9 +2761,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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3102,21 +3185,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}));
|
||||
}
|
||||
@@ -3133,47 +3218,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);
|
||||
}
|
||||
@@ -3539,16 +3632,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;
|
||||
@@ -3565,7 +3658,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")] {
|
||||
@@ -3622,7 +3715,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");
|
||||
|
||||
@@ -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,13 @@ 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))
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -157,7 +157,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 +170,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 +188,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 +219,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() {
|
||||
|
||||
+261
-25
@@ -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,46 @@ 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 {
|
||||
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 +357,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)
|
||||
}
|
||||
|
||||
/// Pen and laser are different pointers: turning one on puts the other away.
|
||||
fn set_pen(&mut self, on: bool) {
|
||||
self.pen = on;
|
||||
@@ -674,10 +832,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;
|
||||
@@ -788,28 +967,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) {
|
||||
@@ -1083,7 +1243,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![]);
|
||||
@@ -2008,7 +2171,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)
|
||||
@@ -2034,7 +2197,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
|
||||
@@ -2583,6 +2747,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);
|
||||
@@ -2712,4 +2904,48 @@ 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);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -74,8 +74,8 @@ 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 +88,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 +200,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 +401,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 +453,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 +494,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 +520,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}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 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)
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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…");
|
||||
}
|
||||
|
||||
#[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… #");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
@@ -65,15 +65,7 @@ struct Done {
|
||||
}
|
||||
|
||||
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 +243,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)?;
|
||||
|
||||
@@ -22,9 +22,9 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim);
|
||||
// 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 lr = p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim);
|
||||
if ui.interact(lr.expand(3.0), ui.id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() {
|
||||
if ui.interact(lr.expand(3.0), ui.scope_id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("language"));
|
||||
}
|
||||
// Transient status message.
|
||||
@@ -52,7 +52,15 @@ 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 lz = z.ln();
|
||||
let s = right.add_sized(vec2(110.0, 18.0), egui::Slider::new(&mut lz, (0.1f32).ln()..=(4.0f32).ln()).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 lz, (0.1f32).ln()..=(4.0f32).ln()).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.
|
||||
let enabled = s.enabled();
|
||||
s.widget_info(|| egui::WidgetInfo::slider(enabled, z as f64, "Zoom"));
|
||||
if s.changed() {
|
||||
z = lz.exp();
|
||||
app.ui.zoom = Some(z);
|
||||
@@ -125,7 +133,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;
|
||||
|
||||
+176
-2
@@ -172,7 +172,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;
|
||||
}
|
||||
@@ -190,6 +197,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;
|
||||
@@ -234,10 +250,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();
|
||||
@@ -245,7 +266,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));
|
||||
}
|
||||
@@ -257,6 +282,155 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
#[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 thin_punctuation_is_drawn_in_interface_text() {
|
||||
// En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face,
|
||||
|
||||
@@ -48,7 +48,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 +60,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);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
@@ -128,6 +128,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:?}")
|
||||
}
|
||||
|
||||
@@ -18,11 +18,18 @@ 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
|
||||
}
|
||||
|
||||
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 +88,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 +199,14 @@ mod tests {
|
||||
FontDb::global().face(family, "Regular")
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
+265
-28
@@ -31,28 +31,43 @@ 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;
|
||||
|
||||
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 +77,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)
|
||||
}
|
||||
|
||||
@@ -114,14 +130,30 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selecte
|
||||
}
|
||||
|
||||
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 +166,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 +190,53 @@ 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
|
||||
}
|
||||
|
||||
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 +244,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 +266,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 +275,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 +346,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 +458,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… #");
|
||||
@@ -425,12 +513,12 @@ pub fn gallery(
|
||||
/// 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> {
|
||||
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!("{id} value"));
|
||||
if r.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(key, text.clone()));
|
||||
} else {
|
||||
@@ -444,7 +532,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 {id}")));
|
||||
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 {id}")));
|
||||
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 +585,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 +603,151 @@ 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");
|
||||
|
||||
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));
|
||||
|
||||
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 +798,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
@@ -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 |
|
||||
|
||||
@@ -19,6 +19,12 @@ set -euo pipefail
|
||||
HERE="$ROOT/packaging/linux"
|
||||
APP_ID=ai.storyteller.deckcraft
|
||||
|
||||
verify_sha256() {
|
||||
local expected="$1"
|
||||
local file="$2"
|
||||
printf '%s %s\n' "$expected" "$file" | sha256sum -c -
|
||||
}
|
||||
|
||||
SKIP_BUILD=0
|
||||
FORMATS="appimage deb rpm tar"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -137,21 +143,21 @@ if has appimage; then
|
||||
download="$TOOL.download"
|
||||
curl --proto '=https' -fsSL -o "$download" \
|
||||
"https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage"
|
||||
printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c -
|
||||
verify_sha256 "$APPIMAGETOOL_SHA256" "$download"
|
||||
chmod 755 "$download"
|
||||
mv "$download" "$TOOL"
|
||||
fi
|
||||
printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c -
|
||||
verify_sha256 "$APPIMAGETOOL_SHA256" "$TOOL"
|
||||
fi
|
||||
RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH"
|
||||
if [[ ! -f "$RUNTIME" ]]; then
|
||||
download="$RUNTIME.download"
|
||||
curl --proto '=https' -fsSL -o "$download" \
|
||||
"https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH"
|
||||
printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$download" | sha256sum -c -
|
||||
verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$download"
|
||||
mv "$download" "$RUNTIME"
|
||||
fi
|
||||
printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" | sha256sum -c -
|
||||
verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME"
|
||||
# Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be
|
||||
# relative).
|
||||
OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage"
|
||||
|
||||
@@ -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,11 +41,19 @@
|
||||
<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="DeckcraftExe" Source="$(var.BinDir)\deckcraft.exe" KeyPath="yes">
|
||||
|
||||
@@ -90,11 +90,25 @@ Copy-Item (Join-Path $Bin 'deckcraft.exe'), (Join-Path $Bin 'deckcraft-cli.exe')
|
||||
|
||||
& (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.
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ const ASSET_EXT: &[&str] = &[
|
||||
const ASSET_DIRS: &[&str] = &["assets/", "docs/images/", "docs/brand/", "examples/"];
|
||||
|
||||
pub fn is_asset(path: &str) -> bool {
|
||||
let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
let ext = Path::new(path).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
ASSET_DIRS.iter().any(|d| path.starts_with(d)) || ASSET_EXT.contains(&ext.as_str())
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -207,7 +207,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|v| v.to_string());
|
||||
out.sort_by_key(ToString::to_string);
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
+11
-10
@@ -6,7 +6,7 @@ use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const SHARED_REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8";
|
||||
const SHARED_REV: &str = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef";
|
||||
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
@@ -72,8 +72,8 @@ fn validate(metadata: &Value) -> Result<(), String> {
|
||||
};
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
||||
for (name, version, expected_source, expected_features) in [
|
||||
("craft-pdf", "0.1.0", source.as_str(), vec!["write"]),
|
||||
("krilla", "0.8.2", source.as_str(), vec!["default", "raster-images", "rayon", "simple-text"]),
|
||||
("craft-pdf", "0.1.0", source.as_str(), vec!["write", "write-simple-text"]),
|
||||
("krilla", "0.8.2", source.as_str(), vec!["raster-images", "rayon", "simple-text"]),
|
||||
("hayro-syntax", "0.7.2", REGISTRY, vec!["std"]),
|
||||
] {
|
||||
let package = one_package(name)?;
|
||||
@@ -119,7 +119,7 @@ fn validate(metadata: &Value) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn validate_target_features(tree: &str, parallel: bool) -> Result<(), String> {
|
||||
let mut expected = BTreeSet::from(["default", "raster-images", "simple-text"]);
|
||||
let mut expected = BTreeSet::from(["raster-images", "simple-text"]);
|
||||
if parallel {
|
||||
expected.insert("rayon");
|
||||
}
|
||||
@@ -153,8 +153,8 @@ mod tests {
|
||||
],
|
||||
"resolve":{"nodes":[
|
||||
{"id":"app", "deps":[{"pkg":"writer", "dep_kinds":[{"kind":null}]}, {"pkg":"parser", "dep_kinds":[{"kind":"dev"}]}]},
|
||||
{"id":"writer", "features":["write"], "deps":[{"pkg":"backend", "dep_kinds":[{"kind":null}]}]},
|
||||
{"id":"backend", "features":["default","raster-images","rayon","simple-text"], "deps":[]},
|
||||
{"id":"writer", "features":["write","write-simple-text"], "deps":[{"pkg":"backend", "dep_kinds":[{"kind":null}]}]},
|
||||
{"id":"backend", "features":["raster-images","rayon","simple-text"], "deps":[]},
|
||||
{"id":"parser", "features":["std"], "deps":[]}
|
||||
]}
|
||||
})
|
||||
@@ -162,8 +162,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parallel_compression_is_required_only_on_native_targets() {
|
||||
let serial = "krilla v0.8.2 (pinned)|default,raster-images,simple-text\n";
|
||||
let parallel = "krilla v0.8.2 (pinned)|default,raster-images,rayon,simple-text\n";
|
||||
let serial = "krilla v0.8.2 (pinned)|raster-images,simple-text\n";
|
||||
let parallel = "krilla v0.8.2 (pinned)|raster-images,rayon,simple-text\n";
|
||||
assert!(validate_target_features(serial, false).is_ok());
|
||||
assert!(validate_target_features(parallel, true).is_ok());
|
||||
assert!(validate_target_features(serial, true).is_err());
|
||||
@@ -189,8 +189,9 @@ mod tests {
|
||||
fn rejects_import_features_and_missing_text_shaping() {
|
||||
for (node, features) in [
|
||||
(1, json!([])),
|
||||
(1, json!(["write", "write-pdf"])),
|
||||
(2, json!(["default", "raster-images", "rayon", "simple-text", "pdf"])),
|
||||
(1, json!(["write"])),
|
||||
(1, json!(["write", "write-simple-text", "write-pdf"])),
|
||||
(2, json!(["raster-images", "rayon", "simple-text", "pdf"])),
|
||||
(2, json!(["raster-images"])),
|
||||
(3, json!(["std", "images"])),
|
||||
] {
|
||||
|
||||
Reference in New Issue
Block a user