Merge main into the integration batch

This commit is contained in:
Federico Olivares
2026-10-25 17:31:11 -03:00
253 changed files with 9037 additions and 1730 deletions
+15 -2
View File
@@ -125,7 +125,7 @@ jobs:
libgtk-3-dev
# The tests build with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run. Same commit as release.yml.
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
# Cargo.toml's dev profile already keeps line tables only.
dev-debug: ""
env: |
@@ -156,11 +156,24 @@ jobs:
# A panic still names its file and line; only backtrace frames lose theirs (Linux keeps
# line-tables-only, so a backtrace with lines is one OS away).
CARGO_PROFILE_DEV_DEBUG: "0"
# The runner has no Japanese or Simplified Chinese system font, and egui 0.37 panics on a
# missing glyph: the localized UI tests need the same craft-fonts input the gate and FreeBSD
# build with (crates/text/build.rs). Same commit as the gate above and release.yml.
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: b076417b5880bd4154bd22f00695b1d28c6bea83
path: craft-fonts
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: clippy
@@ -385,7 +398,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # nightly
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: crates/${{ matrix.target.crate }}/fuzz
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
name: mdBook build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- name: Install mdBook
run: cargo install mdbook --version 0.4.52 --locked
+1 -1
View File
@@ -32,7 +32,7 @@ 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: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
cargo-build-extra-args: "--features heif"
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at
# once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests.
+3 -3
View File
@@ -40,7 +40,7 @@ jobs:
runs-on: macos-15
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
persist-credentials: false
@@ -72,7 +72,7 @@ jobs:
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: perf-results
path: |
@@ -83,7 +83,7 @@ jobs:
- name: Upload baseline
if: always() && inputs.update_baseline
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: perf-baseline
path: perf/baseline.json
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: photocraft
app-title: PhotoCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+1
View File
@@ -26,6 +26,7 @@ next to them.
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
Generated
+292 -120
View File
@@ -4,9 +4,9 @@ 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 = [
"enumn",
"serde",
@@ -15,12 +15,12 @@ dependencies = [
[[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",
@@ -29,43 +29,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.2",
"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.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -73,9 +67,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",
@@ -91,13 +85,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",
"windows-core",
@@ -105,11 +99,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",
@@ -277,7 +272,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -288,7 +283,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -893,7 +888,7 @@ dependencies = [
"maybe-owned",
"rustix 1.1.5",
"rustix-linux-procfs",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
"winx",
]
@@ -1322,7 +1317,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b76109da11416e9ca4ea719#3d37da389a3618ab9b76109da11416e9ca4ea719"
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.8.9",
@@ -1335,12 +1330,37 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"log",
"plist",
"semver",
"serde",
"serde_json",
"sha2",
"tar",
"tempfile",
"ureq",
"verifysign",
"winreg",
"winsafe",
"zip",
]
[[package]]
name = "craft-winprint-gdi"
version = "0.1.0"
@@ -1524,6 +1544,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "deranged"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]]
name = "dify"
version = "0.8.0"
@@ -1675,9 +1701,8 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecolor"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1686,9 +1711,8 @@ dependencies = [
[[package]]
name = "eframe"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1711,6 +1735,7 @@ dependencies = [
"profiling",
"raw-window-handle",
"ron",
"send_wrapper",
"serde",
"static_assertions",
"wasm-bindgen",
@@ -1723,9 +1748,8 @@ dependencies = [
[[package]]
name = "egui"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1745,9 +1769,8 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1765,31 +1788,34 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit_winit",
"arboard",
"block2 0.6.2",
"bytemuck",
"egui",
"image",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"parking_lot",
"profiling",
"raw-window-handle",
"serde",
"smithay-clipboard",
"web-time",
"windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_extras"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
@@ -1802,9 +1828,8 @@ dependencies = [
[[package]]
name = "egui_glow"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1816,9 +1841,8 @@ dependencies = [
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"dify",
"eframe",
@@ -1843,9 +1867,8 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "emath"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"serde",
@@ -1873,22 +1896,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "2.7.3"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "0.17.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.6",
]
[[package]]
@@ -1925,9 +1948,8 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1944,16 +1966,17 @@ dependencies = [
"serde",
"skrifa",
"smallvec",
"unicode-bidi",
"unicode-general-category",
"unicode-properties",
"unicode-segmentation",
"vello_cpu",
]
[[package]]
name = "epaint_default_fonts"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equator"
@@ -1988,7 +2011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2534,9 +2557,9 @@ dependencies = [
[[package]]
name = "glifo"
version = "0.2.0"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash 0.2.0",
@@ -3145,7 +3168,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -3489,12 +3512,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
"accesskit_consumer",
]
[[package]]
@@ -3813,7 +3836,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -3896,6 +3919,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "no_std_io2"
version = "0.9.4"
@@ -3956,6 +3991,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -4565,7 +4606,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -4699,9 +4740,9 @@ dependencies = [
[[package]]
name = "phf"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros",
"phf_shared",
@@ -4710,9 +4751,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",
@@ -4720,9 +4761,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",
@@ -4733,9 +4774,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",
]
@@ -4749,6 +4790,7 @@ dependencies = [
"chrono",
"clap",
"core-graphics2",
"craft-update",
"dispatch2",
"eframe",
"egui",
@@ -4776,10 +4818,10 @@ dependencies = [
"resvg",
"rfd",
"ron",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"ureq",
"winit",
"winresource",
"winsafe",
@@ -5166,6 +5208,7 @@ dependencies = [
"base64 0.22.1",
"craft-print",
"craft-ui",
"craft-update",
"eframe",
"egui",
"egui_extras",
@@ -5199,6 +5242,7 @@ dependencies = [
"tempfile",
"toml",
"unicode-bidi",
"web-time",
"winsafe",
]
@@ -5326,6 +5370,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
dependencies = [
"base64 0.23.1",
"indexmap",
"quick-xml 0.42.0",
"serde",
"time",
]
[[package]]
name = "png"
version = "0.17.16"
@@ -5413,6 +5470,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -5576,6 +5639,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.12"
@@ -5630,7 +5702,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6164,7 +6236,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6233,7 +6305,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6401,6 +6473,16 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "send_wrapper"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
@@ -6871,7 +6953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7101,7 +7183,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7167,6 +7249,36 @@ dependencies = [
"zune-jpeg 0.5.15",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -7481,6 +7593,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typed-path"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typeid"
version = "1.0.3"
@@ -7501,7 +7619,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7589,6 +7707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [
"base64 0.23.1",
"flate2",
"log",
"percent-encoding",
"rustls",
@@ -7596,7 +7715,6 @@ dependencies = [
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
@@ -7693,9 +7811,9 @@ dependencies = [
[[package]]
name = "vello_common"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd",
@@ -7708,9 +7826,9 @@ dependencies = [
[[package]]
name = "vello_cpu"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
"glifo",
@@ -7718,6 +7836,16 @@ dependencies = [
"vello_common",
]
[[package]]
name = "verifysign"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51"
dependencies = [
"core-foundation 0.10.1",
"windows-sys 0.61.2",
]
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -8041,7 +8169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.41.0",
"quote",
]
@@ -8101,15 +8229,6 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -8311,7 +8430,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -8603,8 +8722,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -8681,6 +8799,16 @@ dependencies = [
"xml",
]
[[package]]
name = "winreg"
version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
]
[[package]]
name = "winresource"
version = "0.1.31"
@@ -8843,7 +8971,7 @@ checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566"
dependencies = [
"bitflags 2.13.2",
"libc",
"quick-xml",
"quick-xml 0.41.0",
]
[[package]]
@@ -9141,6 +9269,23 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "zip"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap",
"memchr",
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -9153,6 +9298,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.4.12"
@@ -9241,3 +9398,18 @@ dependencies = [
"syn 3.0.6",
"winnow",
]
[[patch.unused]]
name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+30 -5
View File
@@ -10,7 +10,9 @@ license = "MIT OR Apache-2.0"
rust-version = "1.95"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# egui 0.37 port with capture; PhotoCraft uses its old docking surface, so do not bump this
# to the new-docking L revision without porting panel_docking.rs.
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
# internal
photocraft-geom = { path = "crates/geom" }
photocraft-color = { path = "crates/color" }
@@ -39,9 +41,13 @@ photocraft-tablet = { path = "crates/tablet" }
photocraft-ipp = { path = "crates/ipp" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
# Kept at the egui-0.37 port revision on purpose: 25da contains 4f85efb's bounded print
# parsing and 2bbef53's printer names; 3d37da38 (PhotoCraft main's pin) lacks both. This is a
# targeted security/UI bump, not an all-libs flatten. Re-check if main backports them.
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -56,11 +62,11 @@ image = { version = "0.25", default-features = false }
zune-core = "0.5"
zune-jpeg = "0.5"
proptest = "1"
eframe = { version = "0.36", default-features = false }
eframe = { version = "0.37", default-features = false }
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
# (egui_fonts.rs; the web build's size gate).
egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = { version = "0.37", default-features = false }
egui_kittest = "0.37"
rfd = "0.17"
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
@@ -68,6 +74,8 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
criterion = { version = "0.5", default-features = false }
getrandom = "0.3"
# Monotonic clock on native and wasm (screenshot deadlines: a wall clock can jump).
web-time = "1"
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -176,3 +184,20 @@ opt-level = "z"
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+17
View File
@@ -22,3 +22,20 @@ Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
ship with the package as OFL-<family>.txt.
The egui-icons font subset is embedded from epaint_default_fonts by crates/ui-egui/build.rs
and registered in the native and web fallback stacks by crates/ui-egui/src/egui_fonts.rs.
It is a subset of emoji-icon-font by John Slegers; the same upstream MIT license applies.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following notice is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+1 -1
View File
@@ -29,7 +29,7 @@ log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
eframe = { workspace = true, features = ["wgpu", "accesskit", "clipboard"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
+9 -2
View File
@@ -80,6 +80,7 @@ pub fn start() {
canvas,
options,
Box::new(move |cc| {
PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx);
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
let place_inbox: Inbox = Arc::default();
@@ -328,6 +329,10 @@ impl eframe::App for WebShell {
// A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the
// twin press closes the menu the first one opened (#2095).
photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input);
// The browser build has no TCP control server, so `inject_synthetic` finds an empty
// queue, but the clipboard key mapping (`Event::PasteImage`), legacy pinch, pen-hold and
// titlebar normalization live in the app hook and must run here too.
eframe::App::raw_input_hook(&mut self.app, ctx, raw_input);
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
@@ -356,8 +361,9 @@ impl eframe::App for WebShell {
});
}
self.serve_fonts(ctx);
self.app.logic(ctx, frame);
self.app.logic_frame(ctx, frame);
self.save_presets(ctx);
self.app.finish_type_reset_logic(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
@@ -384,8 +390,9 @@ impl eframe::App for WebShell {
});
});
}
self.app.ui(ui, frame);
self.app.ui_frame(ui, frame);
self.save_presets(ui.ctx());
self.app.finish_type_reset_pass(ui.ctx());
}
}
+3 -2
View File
@@ -29,8 +29,9 @@ photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence", "clipboard"] }
egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
@@ -38,13 +39,13 @@ clap = { workspace = true }
thiserror = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { version = "3.6", features = ["wayland-data-control"] }
open = "5"
# Reads the saved window layout (eframe persistence) to drop values that would abort startup.
ron = "0.12"
# Background release checks; HTTPS with Rust TLS, no async runtime.
ureq = { version = "3.4", default-features = false, features = ["rustls", "platform-verifier"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
+105 -1
View File
@@ -111,6 +111,9 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
.with_titlebar_shown(false)
.with_title_shown(false),
centered: true,
// PhotoCraft owns its fallback fonts (`cjk_fonts`, craft-fonts): eframe's system-font
// provider would compete with it and make glyph choice machine-dependent (#8512).
system_font_fallback: false,
// eframe saves native window geometry and egui panel/window sizes on exit.
// Keep that state beside preferences, including config overrides and portable mode.
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
@@ -147,6 +150,15 @@ fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
crash_guard::install_hook();
#[cfg(not(target_arch = "wasm32"))]
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::PHOTOCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("PhotoCraft update helper failed: {e}");
std::process::exit(1);
}
return Ok(());
}
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
photocraft_ui_egui::type_tool::warm_fonts();
// `--help` and `--version` print to standard output and exit 0. A usage error (an unknown
@@ -411,6 +423,7 @@ fn main() -> eframe::Result {
};
#[cfg(not(target_os = "linux"))]
let session = Session::new();
PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx);
let mut app = PhotocraftApp::new(session, services);
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
app.integrated_titlebar = cfg!(target_os = "macos");
@@ -668,10 +681,101 @@ mod tests {
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
});
output.textures_delta.clear();
let panel = egui::containers::panel::PanelState::load(&restored, egui::Id::new("dock")).unwrap();
let panel = egui::containers::panel::PanelState::load(&restored, photocraft_ui_egui::panels::panel_state_id(&restored, "dock")).unwrap();
assert_eq!(panel.size().x, 410.0);
}
/// A 0.36 `ui.ron` saved panel widths under `Id::new("dock")`; the first 0.37 launch copies
/// them to the parent-scoped key, leaves the old one for a rollback, and a later resize
/// survives two more restarts.
#[test]
fn saved_036_panel_widths_migrate_and_survive_two_restarts() {
#[derive(Default)]
struct Storage(std::collections::BTreeMap<String, String>);
impl eframe::Storage for Storage {
fn get_string(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
self.0.insert(key.into(), value);
}
fn remove_string(&mut self, key: &str) {
self.0.remove(key);
}
fn flush(&mut self) {}
}
let state = |width: f32| egui::containers::panel::PanelState { outer_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 600.0)) };
let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() };
let draw = |ctx: &egui::Context| {
let mut output = ctx.run_ui(input(), |ui| {
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
});
output.textures_delta.clear();
};
let save = |ctx: &egui::Context| {
let mut storage = Storage::default();
ctx.memory(|memory| eframe::set_value(&mut storage, "egui", memory));
storage
};
let restore = |storage: &Storage| {
let ctx = egui::Context::default();
ctx.memory_mut(|memory| *memory = eframe::get_value(storage, "egui").expect("the saved memory"));
ctx
};
// The real 0.14/0.15 shape: the panel state under the old key inside the egui memory.
let ctx = egui::Context::default();
ctx.data_mut(|d| d.insert_persisted(egui::Id::unique("dock"), state(410.0)));
let storage = save(&ctx);
let first = restore(&storage);
photocraft_ui_egui::panels::migrate_panel_state(&first);
draw(&first);
let key = photocraft_ui_egui::panels::panel_state_id(&first, "dock");
assert_eq!(egui::containers::panel::PanelState::load(&first, key).map(|p| p.size().x), Some(410.0));
assert!(egui::containers::panel::PanelState::load(&first, egui::Id::unique("dock")).is_some(), "the 0.36 key is kept");
// Resize in 0.37: the new key wins from now on.
first.data_mut(|d| d.insert_persisted(key, state(500.0)));
let storage = save(&first);
for restart in 0..2 {
let next = restore(&storage);
photocraft_ui_egui::panels::migrate_panel_state(&next);
draw(&next);
let key = photocraft_ui_egui::panels::panel_state_id(&next, "dock");
let width = egui::containers::panel::PanelState::load(&next, key).map(|p| p.size().x).unwrap_or_default();
assert!((width - 500.0).abs() < 0.5, "restart {restart}: the old 410 key must not win (got {width})");
}
}
/// Schema-probe `ui.ron` shapes *reconstructed* from the documented 0.14/0.15/0.37 layouts,
/// not recorded machine saves: 0.14 and 0.15 both store the flat window entry, which the
/// first 0.37 launch rewrites; a 0.37 file is left alone. Keeping all three probes makes a
/// future change to any migration rule fail here first. They do not prove eframe's real
/// `Memory`/`PanelState` deserialization.
#[test]
fn the_ui_ron_schema_probe_fixtures_migrate() {
for (name, fixture) in [("0.14", include_str!("../tests/fixtures/ui-0.14.ron")), ("0.15", include_str!("../tests/fixtures/ui-0.15.ron"))] {
let text = super::ui_state::migrate_for_test(fixture).unwrap_or_else(|e| panic!("{name}: {e}")).unwrap_or_else(|| panic!("{name}: not rewritten"));
assert!(text.contains("normal_geometry"), "{name}: the flat window entry is gone: {text}");
assert!(!super::ui_state::saved_position_for_test(fixture), "{name}: a flat entry is not what 0.37 restores");
}
let old = include_str!("../tests/fixtures/ui-0.14.ron");
assert!(super::ui_state::migrate_for_test(old).expect("migratable").expect("rewritten").contains("x:1100.0"), "the 0.14 size is preserved");
let current = include_str!("../tests/fixtures/ui-0.15.ron");
assert!(
super::ui_state::migrate_for_test(current).expect("migratable").expect("rewritten").contains("maximized:true"),
"0.15 maximized state survives"
);
// Already 0.37-shaped: no rewrite, and (with no saved position) the window centres.
let current = include_str!("../tests/fixtures/ui-0.37.ron");
assert_eq!(super::ui_state::migrate_for_test(current), Ok(None));
assert!(!super::ui_state::saved_position_for_test(current));
// And a 0.37 file with a restorable outer position is a saved position.
let with_position = current.replace("outer_position_pixels:None", "outer_position_pixels:Some((x:2048.0,y:1228.0))");
assert!(super::ui_state::saved_position_for_test(&with_position));
}
#[test]
fn the_window_opens_centred_at_its_default_size() {
let o = super::native_options(super::CUSTOM_TITLEBAR);
+317 -17
View File
@@ -6,16 +6,32 @@
//! hand-edited file would then crash every launch. [`sanitize`] runs first and removes a file
//! holding such values (the window and panels open at their defaults; nothing else is lost).
//! A file that isn't RON at all is left alone: eframe already ignores it.
//!
//! egui 0.37 also replaced the flat `WindowSettings` (`inner_position_pixels`,
//! `inner_size_points: Option<Vec2>`, …) with `normal_geometry: { outer_position_pixels,
//! scale_factor, inner_size_points }`. eframe reads the file before any application code, so an
//! old value cannot be interpreted lazily: [`sanitize`] rewrites it before eframe loads it and
//! keeps a one-time `ui.ron.pre-0.37` backup. The old panel keys in the egui memory part are
//! untouched (`panels::panel_state_id` migrates them additively), but this window rewrite is
//! one-way: after it, a 0.36 build ignores the window entry and opens at its default geometry.
//! Restoring the backup is the documented rollback.
use std::path::Path;
use std::{collections::HashMap, path::Path};
/// Window positions, sizes and panel rects beyond this many points or pixels are corrupt.
const MAX_COORD: f64 = 1.0e6;
/// egui's zoom factor is the UI scale over the display scale (75% on a 2× display is 0.375,
/// 300% on a 1× display is 3).
const ZOOM: std::ops::RangeInclusive<f64> = 0.1..=10.0;
/// The size [`crate::native_options`] opens a window with when an old save has no usable size.
const DEFAULT_SIZE: f32 = 1440.0;
const DEFAULT_HEIGHT: f32 = 900.0;
/// Kept beside `ui.ron` before the one-way 0.36 → 0.37 window rewrite.
const BACKUP_SUFFIX: &str = "pre-0.37";
/// Remove `path` if it holds state that would crash startup. Never fails: errors are logged.
/// Remove `path` if it holds state that would crash startup, and migrate an old window entry to
/// the 0.37 schema before eframe reads it. Never fails: errors are logged and leave the file
/// alone (eframe then ignores what it cannot read).
pub fn sanitize(path: Option<&Path>) {
let Some(path) = path else { return };
let Ok(text) = std::fs::read_to_string(path) else { return };
@@ -24,30 +40,72 @@ pub fn sanitize(path: Option<&Path>) {
if let Err(e) = std::fs::remove_file(path) {
log::warn!("could not remove {}: {e}", path.display());
}
return;
}
match migrate_window(text) {
Ok(Some(text)) => write_migrated(path, &text),
Ok(None) => {}
Err(why) => log::warn!("not rewriting the saved window geometry in {}: {why}", path.display()),
}
}
/// Whether `path` holds a window position from an earlier session, which eframe restores. Then
/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry,
/// Write the migrated file, keeping a one-time backup. A failed backup aborts the rewrite: the
/// pre-0.37 window geometry is the documented rollback, so never destroy the only copy.
fn write_migrated(path: &Path, text: &str) {
let backup = backup_path(path);
if !backup.exists()
&& let Err(e) = std::fs::copy(path, &backup)
{
log::warn!("could not back up {} to {}: {e}; leaving it unchanged", path.display(), backup.display());
return;
}
if let Err(e) = std::fs::write(path, text) {
log::warn!("could not write the migrated window layout to {}: {e}", path.display());
}
}
fn backup_path(path: &Path) -> std::path::PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_else(|| "ui.ron".into());
name.push(format!(".{BACKUP_SUFFIX}"));
path.with_file_name(name)
}
/// Whether `path` holds a window position that eframe restores, which means
/// `NativeOptions::centered` must be off: eframe applies `centered` after the restored geometry,
/// so it moved the window back to the centre of the main monitor on every launch (#4826).
///
/// Only the 0.37 `normal_geometry.outer_position_pixels` counts: `egui_winit::WindowGeometry`
/// sets it and `initialize_viewport_builder` applies it. A 0.36 flat entry is not restored by
/// 0.37 (`sanitize` rewrites it first), and its macOS `inner_position_pixels` was never an outer
/// position.
pub fn has_saved_position(path: Option<&Path>) -> bool {
let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false };
saved_position(&text)
}
fn saved_position(text: &str) -> bool {
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
let Ok(kv) = ron::from_str::<HashMap<String, String>>(text) else { return false };
let Some(window) = kv.get("window").and_then(|w| ron::from_str::<ron::Value>(w).ok()) else { return false };
// eframe restores the inner position on macOS and the outer position elsewhere
// (`egui_winit::WindowSettings::initialize_viewport_builder`).
let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" };
matches!(field(&window, key), Some(ron::Value::Option(Some(_))))
let Some(geometry) = field(&window, "normal_geometry") else { return false };
matches!(field(geometry, "outer_position_pixels"), Some(ron::Value::Option(Some(_))))
}
/// The 0.36 → 0.37 rewrite of one `ui.ron` text, for the recorded 0.14/0.15 fixtures.
#[cfg(test)]
pub(crate) fn migrate_for_test(text: &str) -> Result<Option<String>, String> {
migrate_window(text.to_owned())
}
/// [`saved_position`] for the recorded fixtures.
#[cfg(test)]
pub(crate) fn saved_position_for_test(text: &str) -> bool {
saved_position(text)
}
/// `Err` names the first value that would crash startup.
fn check(text: &str) -> Result<(), String> {
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return Ok(()) };
let Ok(kv) = ron::from_str::<HashMap<String, String>>(text) else { return Ok(()) };
for (key, value) in &kv {
// eframe also skips an entry that doesn't parse.
let Ok(v) = ron::from_str::<ron::Value>(value) else { continue };
@@ -60,6 +118,108 @@ fn check(text: &str) -> Result<(), String> {
Ok(())
}
/// `Some(new_text)` when `text` is an old-schema file that should be rewritten; `None` when it
/// is already 0.37-shaped or holds no window entry; `Err` when the old window entry is unusable
/// and must be left untouched.
fn migrate_window(text: String) -> Result<Option<String>, String> {
let Ok(mut kv) = ron::from_str::<HashMap<String, String>>(&text) else { return Ok(None) };
let Some(value) = kv.get("window").cloned() else { return Ok(None) };
let Ok(window) = ron::from_str::<ron::Value>(&value) else { return Ok(None) };
if field(&window, "normal_geometry").is_some() {
return Ok(None);
}
if field(&window, "inner_size_points").is_none() && field(&window, "inner_position_pixels").is_none() && field(&window, "outer_position_pixels").is_none() {
return Ok(None);
}
let migrated = migrate_window_value(&window)?;
kv.insert("window".into(), migrated);
ron::to_string(&kv).map(Some).map_err(|e| format!("cannot write the new window entry: {e}"))
}
/// The RON payload eframe 0.37 deserializes as `egui_winit::WindowSettings`.
///
/// The field order and names mirror `WindowGeometry`; the types are local because
/// `egui_winit::WindowGeometry` keeps its fields private.
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)]
struct PointV37 {
x: f32,
y: f32,
}
impl PointV37 {
fn sane(self) -> Option<Self> {
let ok = |v: f32| v.is_finite() && (v.abs() as f64) <= MAX_COORD;
(ok(self.x) && ok(self.y)).then_some(self)
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)]
struct GeometryV37 {
outer_position_pixels: Option<PointV37>,
scale_factor: f32,
inner_size_points: PointV37,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)]
struct WindowV37 {
normal_geometry: GeometryV37,
fullscreen: bool,
maximized: bool,
}
fn migrate_window_value(window: &ron::Value) -> Result<String, String> {
let size = old_size(window)?;
// The old macOS entry stored an inner (not outer) position in logical points and Linux saved
// no scale factor, so a faithful restore is only possible for the Windows physical outer
// position. Everywhere else the window opens centred with its saved size.
let position = if cfg!(target_os = "windows") { old_position(window, "outer_position_pixels") } else { None };
let window = WindowV37 {
normal_geometry: GeometryV37 {
outer_position_pixels: position,
// Physical pixels of the old Windows position: 0.37 divides by zoom × scale.
scale_factor: 1.0,
inner_size_points: size,
},
fullscreen: old_bool(window, "fullscreen"),
maximized: old_bool(window, "maximized"),
};
ron::to_string(&window).map_err(|e| format!("cannot serialize the new window entry: {e}"))
}
fn old_size(window: &ron::Value) -> Result<PointV37, String> {
let value = field(window, "inner_size_points");
let Some(ron::Value::Option(Some(size))) = value else {
// A missing size is the app's own opening size, not corrupt data.
return Ok(PointV37 { x: DEFAULT_SIZE, y: DEFAULT_HEIGHT });
};
let point = point_value(size).ok_or_else(|| "inner_size_points is not a finite (x, y) point".to_string())?;
let point = point.sane().ok_or_else(|| "inner_size_points is not finite or is absurdly large".to_string())?;
if point.x < 1.0 || point.y < 1.0 {
return Err("inner_size_points has a zero or negative side".into());
}
Ok(point)
}
/// One dimension of an old `Option<Pos2>` field, rejecting corrupt values rather than guessing.
fn old_position(window: &ron::Value, key: &str) -> Option<PointV37> {
match field(window, key) {
Some(ron::Value::Option(Some(p))) => point_value(p).and_then(PointV37::sane),
// `None`, missing, and malformed all mean "no position"; corrupt positions elsewhere are
// caught by `check`, which removes a file with non-finite values before this runs.
_ => None,
}
}
fn old_bool(window: &ron::Value, key: &str) -> bool {
matches!(field(window, key), Some(ron::Value::Bool(true)))
}
fn point_value(v: &ron::Value) -> Option<PointV37> {
let x = field(v, "x").and_then(as_f32)?;
let y = field(v, "y").and_then(as_f32)?;
Some(PointV37 { x, y })
}
/// Every float finite and within [`MAX_COORD`]; depth-limited (the file is untrusted).
fn floats_sane(v: &ron::Value, depth: usize) -> Result<(), String> {
if depth > 64 {
@@ -86,6 +246,13 @@ fn as_f64(v: &ron::Value) -> Option<f64> {
}
}
fn as_f32(v: &ron::Value) -> Option<f32> {
as_f64(v).and_then(|f| {
let f = f as f32;
f.is_finite().then_some(f)
})
}
fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
match v {
ron::Value::Map(m) => m.get(&ron::Value::String(name.into())),
@@ -95,7 +262,8 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
#[cfg(test)]
mod tests {
use super::{check, saved_position};
use super::{DEFAULT_HEIGHT, DEFAULT_SIZE, WindowV37, backup_path, check, migrate_window, saved_position};
use std::{collections::HashMap, path::PathBuf};
fn file(window: &str, zoom: &str) -> String {
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
@@ -103,6 +271,17 @@ mod tests {
}
const WINDOW: &str = "(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,inner_size_points:Some((x:1100.0,y:760.0)))";
const WINDOW_WITH_OUTER: &str = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
/// Read the window payload back as eframe 0.37 would.
fn window_of(text: &str) -> WindowV37 {
let kv: HashMap<String, String> = ron::from_str(text).expect("the migrated file is a RON map");
ron::from_str(kv.get("window").expect("a window entry")).expect("0.37 WindowSettings shape")
}
fn migrated(window: &str) -> String {
migrate_window(file(window, "1.0")).expect("migratable").expect("rewritten")
}
#[test]
fn a_saved_layout_is_kept() {
@@ -122,13 +301,83 @@ mod tests {
}
}
#[test]
fn an_old_size_and_maximized_state_survive() {
let text = migrated(WINDOW_WITH_OUTER);
let window = window_of(&text);
assert_eq!(window.normal_geometry.inner_size_points.x, 880.0);
assert_eq!(window.normal_geometry.inner_size_points.y, 480.0);
assert!(!window.fullscreen);
assert!(!window.maximized);
let maximized = migrated(&WINDOW.replace("fullscreen:false", "fullscreen:true,maximized:true"));
assert!(window_of(&maximized).fullscreen && window_of(&maximized).maximized);
}
#[test]
fn a_missing_size_uses_the_default_opening_size() {
// A window entry with a position but no size migrates and keeps the app's opening size.
let window = window_of(&migrated("(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,maximized:false)"));
assert_eq!(window.normal_geometry.inner_size_points.x, DEFAULT_SIZE);
assert_eq!(window.normal_geometry.inner_size_points.y, DEFAULT_HEIGHT);
// A window entry with no geometry fields at all is left alone: it is not a 0.36 window.
assert_eq!(migrate_window(file("(fullscreen:false,maximized:false)", "1.0")), Ok(None));
// So is a file with no window-like fields at all.
assert_eq!(migrate_window(file("(something:1)", "1.0")), Ok(None));
}
#[test]
fn the_old_position_is_kept_only_on_windows() {
let text = migrated(WINDOW_WITH_OUTER);
let pos = window_of(&text).normal_geometry.outer_position_pixels;
if cfg!(target_os = "windows") {
let pos = pos.expect("the Windows physical outer position survives");
assert_eq!((pos.x, pos.y), (2048.0, 1228.0));
assert_eq!(window_of(&text).normal_geometry.scale_factor, 1.0);
} else {
assert!(pos.is_none(), "an old macOS/Linux position is not a restorable outer position");
}
}
#[test]
fn a_corrupt_old_size_is_not_rewritten() {
let bad = WINDOW.replace("(x:1100.0,y:760.0)", "(x:1100.0,y:0.0)");
assert!(migrate_window(file(&bad, "1.0")).is_err());
let huge = WINDOW.replace("(x:1100.0,y:760.0)", "(x:1100.0,y:9000000.0)");
assert!(migrate_window(file(&huge, "1.0")).is_err());
}
#[test]
fn an_already_migrated_file_is_left_alone() {
let text = migrated(WINDOW);
assert_eq!(migrate_window(text), Ok(None));
// A 0.36 build accepts the entry, but its missing flat geometry defaults to None.
// It cannot recover position or size from the nested 0.37 geometry.
let kv: HashMap<String, String> = ron::from_str(&migrated(WINDOW)).unwrap();
let old: OldWindow = ron::from_str(kv.get("window").unwrap()).unwrap();
assert!(old.inner_position_pixels.is_none() && old.outer_position_pixels.is_none());
assert!(old.inner_size_points.is_none(), "documented rollback loss of saved geometry");
// Restoring the original backup restores both the old position and size.
let restored: OldWindow = ron::from_str(WINDOW).unwrap();
let position = restored.inner_position_pixels.unwrap();
let size = restored.inner_size_points.unwrap();
assert_eq!((position.x, position.y), (616.0, 430.0));
assert_eq!((size.x, size.y), (1100.0, 760.0));
}
#[test]
fn a_saved_window_position_is_detected() {
// #4826: the position from the report (Linux Mint, window moved to the third monitor).
let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(saved_position(&file(window, "1.0")));
let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
// A 0.36 flat entry is not what eframe 0.37 restores (it would open at the OS default);
// `sanitize` rewrites it before eframe runs, and the migrated nested value is detected.
assert!(!saved_position(&file(WINDOW_WITH_OUTER, "1.0")), "an unmigrated flat entry is not a restorable position");
// An old macOS inner position is not an outer position either.
assert!(!saved_position(&file(WINDOW, "1.0")));
let no_position =
"(normal_geometry:(outer_position_pixels:None,scale_factor:1.0,inner_size_points:(x:880.0,y:480.0)),fullscreen:false,maximized:false)";
assert!(!saved_position(&file(no_position, "1.0")));
// The migrated nested geometry is what a 0.37 save holds (on Windows only, from the
// migration; a later 0.37 save on macOS/Linux has one too and eframe restores it).
let text = migrated(WINDOW_WITH_OUTER);
assert_eq!(saved_position(&text), cfg!(target_os = "windows"));
assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry");
for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] {
assert!(!saved_position(text), "{text:?}");
@@ -148,7 +397,7 @@ mod tests {
}
#[test]
fn sanitize_removes_only_a_bad_file() {
fn sanitize_removes_only_a_bad_file_and_migrates_a_good_one() {
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let good = dir.join("good.ron");
@@ -159,7 +408,58 @@ mod tests {
super::sanitize(Some(&bad));
super::sanitize(Some(&dir.join("missing.ron")));
super::sanitize(None);
assert!(good.exists() && !bad.exists());
assert!(!bad.exists());
assert!(good.exists());
// Migrated in place, with the one-time pre-0.37 backup beside it.
let text = std::fs::read_to_string(&good).unwrap();
assert!(window_of(&text).normal_geometry.inner_size_points.x == 1100.0);
let backup = backup_path(&good);
assert!(backup.exists(), "the pre-0.37 layout is kept for rollback");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), file(WINDOW, "1.0"));
// A second run leaves both untouched.
super::sanitize(Some(&good));
assert_eq!(std::fs::read_to_string(&good).unwrap(), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_backup_is_not_overwritten_by_a_later_migration() {
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-backup-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ui.ron");
std::fs::write(&path, file(WINDOW, "1.0")).unwrap();
super::sanitize(Some(&path));
let backup = backup_path(&path);
let original = std::fs::read_to_string(&backup).unwrap();
// Pretend a 0.36 run wrote its old schema again; the backup must stay the first one.
std::fs::write(&path, file(&WINDOW.replace("1100.0", "1111.0"), "1.0")).unwrap();
super::sanitize(Some(&path));
assert_eq!(std::fs::read_to_string(&backup).unwrap(), original);
assert!(std::fs::read_to_string(&path).unwrap().contains("1111"));
let _ = std::fs::remove_dir_all(&dir);
}
/// The 0.36 flat schema, kept to prove the new nested value is a one-way migration.
#[derive(Default, serde::Deserialize)]
#[serde(default)]
#[allow(dead_code)]
struct OldWindow {
inner_position_pixels: Option<Point>,
outer_position_pixels: Option<Point>,
fullscreen: bool,
maximized: bool,
inner_size_points: Option<Point>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct Point {
x: f32,
y: f32,
}
#[test]
fn the_backup_path_sits_beside_ui_ron() {
assert_eq!(backup_path(&PathBuf::from("/tmp/x/ui.ron")), PathBuf::from("/tmp/x/ui.ron.pre-0.37"));
}
}
+8 -107
View File
@@ -1,10 +1,7 @@
//! Native HTTPS transport for release checks. Only public release metadata is requested.
//! Release checks through `craft-update`, the library every Craft App shares. Only public release
//! metadata is requested.
use photocraft_engine::updates_cmds::{CheckFn, CheckResult, parse_release};
use std::time::Duration;
const LATEST: &str = "https://api.github.com/repos/storytold/photocraft/releases/latest";
const MAX_RESPONSE: u64 = 1024 * 1024;
use photocraft_engine::updates_cmds::{CheckFn, CheckResult, Release};
pub fn service() -> CheckFn {
Box::new(|| {
@@ -12,7 +9,7 @@ pub fn service() -> CheckFn {
std::thread::Builder::new()
.name("update check".into())
.spawn(move || {
let result = fetch(LATEST, Duration::from_secs(15));
let result = check();
if let Err(e) = &result {
log::warn!("Update check: {e}");
}
@@ -23,104 +20,8 @@ pub fn service() -> CheckFn {
})
}
fn fetch(url: &str, timeout: Duration) -> CheckResult {
// Respect the OS trust store (including managed-network certificates), while still
// validating the certificate chain and server name.
let agent = ureq::Agent::config_builder()
.tls_config(ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build())
.timeout_global(Some(timeout))
.max_redirects(0)
.build()
.new_agent();
let mut response = match agent
.get(url)
.header("User-Agent", "PhotoCraft-update-check")
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2026-03-10")
.call()
{
Ok(response) => response,
// The repository may not have published a stable release yet.
Err(ureq::Error::StatusCode(404)) => return Ok(None),
Err(ureq::Error::StatusCode(403 | 429)) => return Err("GitHub is limiting update checks. Please try again later.".into()),
Err(e) => return Err(format!("Could not contact the release server: {e}")),
};
if response.status() != 200 {
return Err(format!("Unexpected release server status: {}", response.status()));
}
let bytes = response.body_mut().with_config().limit(MAX_RESPONSE).read_to_vec().map_err(|e| format!("Could not read release response: {e}"))?;
parse_release(&bytes, photocraft_engine::build_info::VERSION)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
fn server(status: &str, body: &str, extra: &str) -> (String, std::thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/latest", listener.local_addr().unwrap());
let response = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra}Connection: close\r\n\r\n{body}", body.len());
let worker = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut request = Vec::new();
let mut chunk = [0; 1024];
while !request.windows(4).any(|w| w == b"\r\n\r\n") {
let n = stream.read(&mut chunk).unwrap();
if n == 0 {
break;
}
request.extend_from_slice(&chunk[..n]);
}
let _ = stream.write_all(response.as_bytes());
String::from_utf8(request).unwrap()
});
(url, worker)
}
#[test]
fn fetches_release_metadata_with_generic_headers() {
let (url, worker) = server("200 OK", r#"{"tag_name":"v99.0.0","draft":false,"prerelease":false}"#, "");
let result = fetch(&url, Duration::from_secs(5)).unwrap().unwrap();
assert_eq!(result.version, "99.0.0");
let request = worker.join().unwrap().to_ascii_lowercase();
assert!(request.contains("user-agent: photocraft-update-check\r\n"));
assert!(request.contains("accept: application/vnd.github+json"));
assert!(!request.contains("authorization:") && !request.contains("cookie:"));
}
#[test]
fn handles_missing_release_rate_limits_and_bad_responses() {
for (status, body, extra, expected_none) in [
("404 Not Found", "", "", true),
("403 Forbidden", "", "", false),
("429 Too Many Requests", "", "", false),
("500 Internal Server Error", "", "", false),
("200 OK", "invalid JSON", "", false),
("302 Found", "", "Location: https://untrusted.example/\r\n", false),
] {
let (url, worker) = server(status, body, extra);
let result = fetch(&url, Duration::from_secs(5));
if expected_none {
assert_eq!(result, Ok(None));
} else {
assert!(result.is_err(), "{status}");
}
worker.join().unwrap();
}
}
#[test]
fn caps_response_size_and_times_out() {
let (url, worker) = server("200 OK", &" ".repeat(MAX_RESPONSE as usize + 1), "");
assert!(fetch(&url, Duration::from_secs(5)).is_err());
worker.join().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/latest", listener.local_addr().unwrap());
let start = std::time::Instant::now();
assert!(fetch(&url, Duration::from_millis(100)).is_err());
assert!(start.elapsed() < Duration::from_secs(2));
}
fn check() -> CheckResult {
let http = craft_update::Http::new("PhotoCraft/Update")?;
let found = craft_update::check_for_update(&craft_update::suite::PHOTOCRAFT, &http, photocraft_engine::build_info::VERSION)?;
Ok(found.map(|release| Release { version: release.tag.trim_start_matches(['v', 'V']).to_string(), url: release.html_url }))
}
+7
View File
@@ -0,0 +1,7 @@
These files are **schema probes**, not recorded machine saves.
`ui-0.14.ron`, `ui-0.15.ron` and `ui-0.37.ron` are minimal payloads reconstructed from the
documented eframe/egui layouts. They exercise `apps/photocraft/src/ui_state.rs` (the flat
window entry versus the nested `normal_geometry` entry) only. They do not prove that eframe
and egui deserialize a real `Memory` or `PanelState`; a captured 0.14/0.15/0.37 `ui.ron`
would be needed for that, with the machine-specific data reviewed first.
+1
View File
@@ -0,0 +1 @@
{"window":"(inner_position_pixels:Some((x:616.0,y:430.0)),outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:1100.0,y:760.0)))","egui":"()"}
+1
View File
@@ -0,0 +1 @@
{"window":"(inner_position_pixels:None,outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:true,inner_size_points:Some((x:1440.0,y:900.0)))","egui":"()"}
+1
View File
@@ -0,0 +1 @@
{"window":"(normal_geometry:(outer_position_pixels:None,scale_factor:2.0,inner_size_points:(x:1440.0,y:900.0)),fullscreen:false,maximized:false)","egui":"()"}
+1
View File
@@ -6,6 +6,7 @@ PhotoCraft's release workflow builds platform packages and a draft GitHub Releas
- platform-specific signing/notarization steps exist for macOS and Windows;
- signing secrets are scoped through the GitHub `release` environment;
- the Azure Trusted Signing client that Windows signing loads is pinned to a version and SHA-256, and refused on a mismatch;
- release artifacts include `SHA256SUMS.txt`;
- the final release job receives `contents: write`, while build jobs use narrower access;
- artifacts remain in a draft release until a maintainer publishes them.
+4 -1
View File
@@ -11,7 +11,10 @@
//! shells out to git, so builds work from a source tarball and for wasm.
/// The workspace version, such as `0.1.0` or `0.2.0-rc.1`.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const VERSION: &str = match option_env!("PHOTOCRAFT_OVERRIDE_VERSION") {
Some(v) => v,
None => env!("CARGO_PKG_VERSION"),
};
/// The full git commit this build was made from, when the build environment recorded it.
pub fn git_sha() -> Option<&'static str> {
+2
View File
@@ -967,6 +967,7 @@ impl Session {
return Err(EngineError::Other(format!("`{label}`: the document changed while it ran; the result was discarded")));
}
self.active = Some(i);
self.note_active_change();
Some(i)
}
None => None,
@@ -1022,6 +1023,7 @@ impl Session {
// This also restores the selection when the apply step returned an error.
if target.is_some() && self.active == target {
self.active = prev_active.filter(|i| *i < self.docs.len()).or(self.active);
self.note_active_change();
}
r
}
+25
View File
@@ -470,6 +470,12 @@ pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.n
pub struct Session {
docs: Vec<DocState>,
active: Option<usize>,
/// Monotonic counter, bumped whenever a different document becomes active (including a
/// switch away and back inside one pass). UI work that must not cross documents (control
/// screenshots, automation input) binds to it.
active_generation: u64,
/// The document id [`Self::active_generation`] was last bumped for.
active_doc_for_generation: Option<photocraft_doc::DocId>,
pub tools: ToolState,
/// Log of executed commands (for action recording and debugging).
pub journal: Vec<(String, Value)>,
@@ -581,9 +587,25 @@ impl Session {
pub fn active_mut(&mut self) -> Option<&mut DocState> {
self.active.and_then(|i| self.docs.get_mut(i))
}
/// Monotonic active-document generation. It changes when a different document becomes
/// active, including a switch away and back between two observations; equal values therefore
/// mean the document was not switched in between.
pub fn active_generation(&self) -> u64 {
self.active_generation
}
/// Bump [`Self::active_generation`] when the active document id changed. Call after every
/// assignment to `self.active`.
pub(crate) fn note_active_change(&mut self) {
let id = self.active().map(|st| st.doc.id);
if id != self.active_doc_for_generation {
self.active_doc_for_generation = id;
self.active_generation = self.active_generation.wrapping_add(1);
}
}
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.docs.len() {
self.active = Some(index);
self.note_active_change();
self.sync_tools_mask_target();
true
} else {
@@ -623,6 +645,7 @@ impl Session {
let active = self.active().map(|d| d.doc.id);
let to = move_item(&mut self.docs, from, to)?;
self.active = active.and_then(|id| self.docs.iter().position(|d| d.doc.id == id));
self.note_active_change();
Some(to)
}
@@ -640,6 +663,7 @@ impl Session {
self.docs.push(st);
let i = self.docs.len() - 1;
self.active = Some(i);
self.note_active_change();
i
}
@@ -678,6 +702,7 @@ impl Session {
_ => index.min(self.docs.len() - 1),
})
};
self.note_active_change();
Some(d)
}
+2
View File
@@ -1068,6 +1068,7 @@ pub fn commit_child(s: &mut Session, index: usize) -> Result<bool> {
let bytes = Arc::new(encode_source(&child)?);
let prev = s.active;
s.active = Some(parent);
s.note_active_change();
let r = s.edit("Edit Contents", |doc, _| {
let ids = instances(doc, link.contents_id);
if ids.is_empty() {
@@ -1087,6 +1088,7 @@ pub fn commit_child(s: &mut Session, index: usize) -> Result<bool> {
Ok(())
});
s.active = prev;
s.note_active_change();
r?;
if let Some(st) = s.docs.get_mut(index) {
st.saved_revision = st.revision;
+22
View File
@@ -1848,3 +1848,25 @@ fn history_states_share_large_document_payloads() {
};
assert!(std::sync::Arc::ptr_eq(&raw(now), &raw(then)), "the adjustment's bytes are shared");
}
#[test]
fn the_document_activation_generation_bumps_on_switch_and_back() {
let mut s = session_with_doc();
let first = s.active().unwrap().doc.id;
let g1 = s.active_generation();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let second = s.active().unwrap().doc.id;
assert_ne!(second, first);
let g2 = s.active_generation();
assert!(g2 > g1, "opening a document activates it");
let i = s.documents().iter().position(|d| d.doc.id == first).expect("the first document");
assert!(s.set_active(i));
let g3 = s.active_generation();
assert!(g3 > g2, "switching back is a new activation");
// Re-selecting the already-active document is not a switch; neither is an edit.
assert!(s.set_active(i));
assert_eq!(s.active_generation(), g3);
s.execute("layer.new.layer", json!({})).unwrap();
assert_eq!(s.active_generation(), g3);
assert_eq!(s.active().unwrap().doc.id, first);
}
+6 -4
View File
@@ -30,8 +30,8 @@ photocraft-format = { workspace = true }
# Decoding the brand mark's PNG (`brand.rs`); already linked through photocraft-io.
photocraft-codecs = { workspace = true }
egui = { workspace = true }
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
eframe = { workspace = true, features = ["wgpu", "accesskit", "clipboard"] }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -40,8 +40,10 @@ unicode-bidi = "0.3"
base64 = "0.22"
# Inflating the translation catalogs, stored deflated (build.rs); already linked through photocraft-io.
flate2 = { workspace = true }
web-time = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
rayon = { workspace = true }
# Safe native UI-language APIs (Windows/macOS); no subprocess or web dependency.
sys-locale = "=0.3.2"
@@ -54,7 +56,7 @@ winsafe = { version = "=0.0.29", default-features = false, features = ["kernel",
[dev-dependencies]
# egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with
# `Context::default()` (no install_fonts) can lay out text.
egui = { workspace = true, features = ["default_fonts"] }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
# Synthetic raw files for the open-time Camera Raw dialog tests.
photocraft-raw = { workspace = true, features = ["testgen"] }
egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] }
@@ -70,6 +72,6 @@ tempfile = "3"
# Deflates the translation catalogs and egui's default fonts (build.rs); already linked at run time to inflate them.
flate2 = { workspace = true }
# egui's default fonts, deflated by build.rs for egui_fonts.rs (the same version egui uses).
epaint_default_fonts = "0.36"
epaint_default_fonts = { version = "0.37", features = ["monochrome_emoji_fonts"] }
serde_json = { workspace = true }
toml = "1"
+3
View File
@@ -37,8 +37,11 @@ fn main() {
fn deflate_egui_fonts() {
use std::io::Write as _;
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("egui_fonts");
// `egui-icons` is unconditional in 0.37 (a handful of private-use symbols, 3.5 kB); the two
// emoji fonts come through `monochrome_emoji_fonts` in Cargo.toml.
let fonts = [
("Hack-Regular", epaint_default_fonts::HACK_REGULAR),
("egui-icons", epaint_default_fonts::EGUI_ICONS),
("NotoEmoji-Regular", epaint_default_fonts::NOTO_EMOJI_REGULAR),
("Ubuntu-Light", epaint_default_fonts::UBUNTU_LIGHT),
("emoji-icon-font", epaint_default_fonts::EMOJI_ICON),
+1 -1
View File
@@ -64,7 +64,7 @@ fn main() {
if screen_picker::showing(ui.ctx()) {
screen_picker::show(ui.ctx());
} else {
screen_picker::button(ui, egui::Id::new("demo"));
screen_picker::button(ui, egui::Id::unique("demo"));
}
},
PhotocraftApp::new(photocraft_engine::Session::new(), services),
+14 -14
View File
@@ -454,9 +454,9 @@ fn insert_stop_dialog(app: &mut PhotocraftApp) -> Result<Value, String> {
}
pub fn insert_stop_body(ui: &mut egui::Ui, fields: &mut serde_json::Map<String, Value>) {
ui.label(tl!("Message:"));
let message_label = ui.label(tl!("Message:"));
let mut message = fields.get("message").and_then(Value::as_str).unwrap_or_default().to_string();
ui.add(egui::TextEdit::multiline(&mut message).desired_rows(4).desired_width(320.0));
ui.add(egui::TextEdit::multiline(&mut message).desired_rows(4).desired_width(320.0)).labelled_by(message_label.id);
fields.insert("message".into(), json!(message));
ui.add_space(6.0);
let mut allow = fields.get("allowContinue").and_then(Value::as_bool).unwrap_or(false);
@@ -760,8 +760,8 @@ fn delete_selection(app: &mut PhotocraftApp) {
/// returns the new name to commit; Escape cancels and keeps the old name.
fn rename_field(app: &mut PhotocraftApp, ui: &mut egui::Ui, index: usize, rect: Rect) -> Option<String> {
let mut r = app.ui.actions.renaming.take().filter(|r| r.index == index)?;
let id = egui::Id::new(("action-rename-field", index));
let te = ui.put(rect, egui::TextEdit::singleline(&mut r.text).id(id).font(egui::FontId::proportional(12.0)));
let id = egui::Id::unique(("action-rename-field", index));
let te = ui.put(rect, egui::TextEdit::singleline(&mut r.text).id(id).font(egui::FontId::proportional(12.0))).accessible_name(tl!("Name"));
if !r.focused {
te.request_focus();
// Like Photoshop, the whole name starts selected so typing replaces it.
@@ -901,7 +901,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
shown_set = Some(set);
let collapsed = app.ui.actions.collapsed_sets.iter().any(|c| c == set);
let (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, !collapsed, set));
hresp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, !collapsed, set));
if hresp.hovered() {
ui.painter().rect_filled(hr, 0.0, t.hover.gamma_multiply(0.5));
}
@@ -944,7 +944,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let button_mode = app.ui.actions.button_mode;
let sel = app.ui.actions.selected == Some(i) && app.ui.actions.selected_step.is_none();
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, name));
if button_mode {
ui.painter().rect_filled(rect, 3.0, button_fill(swatch, &t));
} else if sel {
@@ -1067,7 +1067,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let label = step_label(id, params);
let selected = app.ui.actions.selected == Some(i) && app.ui.actions.selected_step == Some(step);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &label));
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &label));
if selected {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if response.hovered() {
@@ -1082,22 +1082,22 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Photoshop's check column: unchecked steps are skipped by playback, Batch and droplets.
let check = ui.interact(
Rect::from_center_size(pos2(x0 + 30.0, r.center().y), vec2(16.0, 16.0)),
ui.id().with(("action-step-check", i, step)),
ui.scope_id().with(("action-step-check", i, step)),
Sense::click(),
);
check.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, format!("{}: {label}", tl!("Include in playback"))));
check.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, on, format!("{}: {label}", tl!("Include in playback"))));
paint_check(ui, check.rect, on, &t);
// Photoshop's dialog column: playback shows the step's dialog, seeded with its
// recorded values, and continues when it is confirmed.
let with_dialog = dialogs.contains(&step);
let dialog = ui.interact(
Rect::from_center_size(pos2(x0 + 46.0, r.center().y), vec2(16.0, 16.0)),
ui.id().with(("action-step-dialog", i, step)),
ui.scope_id().with(("action-step-dialog", i, step)),
Sense::click(),
);
// A toggle button, not a check box, so the check column stays the only CheckBox role.
dialog.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::Button, true, with_dialog, format!("{}: {label}", tl!("Show dialog during playback")))
egui::WidgetInfo::selected(egui::Role::Button, true, with_dialog, format!("{}: {label}", tl!("Show dialog during playback")))
});
crate::icons::paint(ui, dialog.rect, "message-square", 11.0, if with_dialog { t.icon } else { t.text_faint.gamma_multiply(0.45) });
let dialog = dialog.on_hover_text(tl!("Show dialog during playback"));
@@ -1156,7 +1156,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
begin_recording(app, true);
}
let play = crate::icons::button(ui, "play", 24.0, false, tl!("Play selection"));
play.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Play selection")));
play.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Play selection")));
if play.clicked() {
play_idx = playback_selection(app);
}
@@ -1166,14 +1166,14 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let can_delete = app.ui.actions.selected.is_some() && (!recording_now || app.ui.actions.selected_step.is_some());
ui.add_enabled_ui(can_delete, |ui| {
let response = crate::icons::button(ui, "trash", 24.0, false, tl!("Delete"));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_delete, tl!("Delete")));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_delete, tl!("Delete")));
if response.clicked() {
delete_selection(app);
}
});
let mode_label = if app.ui.actions.button_mode { tl!("Button Mode On") } else { tl!("Button Mode") };
let mode_button = ui.add_enabled(!recording_now, egui::Button::new(mode_label).small()).on_hover_text(tl!("Button Mode"));
mode_button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, !recording_now, mode_label));
mode_button.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, !recording_now, mode_label));
if mode_button.clicked() {
app.ui.actions.button_mode = !app.ui.actions.button_mode;
}
+31 -17
View File
@@ -179,7 +179,7 @@ pub(crate) fn picker_button(ui: &mut egui::Ui, selected: bool, enabled: bool, to
let tokens = crate::theme::Tokens::get(ui.ctx());
ui.painter().circle_filled(response.rect.right_bottom() - egui::vec2(5.5, 5.5), 3.5, color);
ui.painter().circle_stroke(response.rect.right_bottom() - egui::vec2(5.5, 5.5), 3.5, egui::Stroke::new(1.0, tokens.field_border));
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, tooltip));
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, selected, tooltip));
response
}
@@ -226,7 +226,7 @@ pub fn owns(fields: &Map<String, Value>) -> bool {
/// Root of the dialog editor's view state in egui memory.
fn dialog_mem(kind: &str) -> egui::Id {
egui::Id::new(("adjust-dialog", kind))
egui::Id::unique(("adjust-dialog", kind))
}
/// Whether holding Alt turns Cancel into Reset for this adjustment dialog (every kind).
@@ -519,16 +519,26 @@ mod tests {
/// shows Midtones, keeps Preserve Luminosity and leaves the dialog open.
#[test]
fn color_balance_dialog_gestures_match_photoshop() {
use egui_kittest::kittest::Queryable;
use egui_kittest::kittest::{NodeT, Queryable};
let (mut h, id) = color_balance_dialog();
let label = h.query_all_by_label("Cyan · Red").last().map(|n| n.rect()).expect("Cyan · Red row");
// The slider row's caption, not the field or slider it names.
let label = h
.query_all(egui_kittest::kittest::by().label("Cyan · Red").include_labels())
.find(|n| n.accesskit_node().role() == egui::accesskit::Role::Label)
.map(|n| n.rect())
.expect("Cyan · Red row");
let at = egui::pos2(label.left() + 60.0, label.bottom() + 14.0);
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::ALT };
input(&mut h, egui::Modifiers::ALT, &[egui::Event::PointerMoved(at), press(true), press(false), press(true), press(false)]);
assert_eq!(field(&h, id, "midtones"), json!([0.0, -30.0, 20.0]), "Alt+double-click zeroes the slider");
assert_eq!(field(&h, id, "shadows"), json!([10, 0, 0]), "other tones untouched");
let wheel =
|n: f32, m| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, n), phase: egui::TouchPhase::Move, modifiers: m };
let wheel = |n: f32, m| egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, n),
phase: egui::TouchPhase::Move,
modifiers: m,
};
input(&mut h, egui::Modifiers::NONE, &[egui::Event::PointerMoved(at), wheel(3.0, egui::Modifiers::NONE)]);
assert_eq!(field(&h, id, "midtones")[0], json!(3.0), "3 notches up");
input(&mut h, egui::Modifiers::SHIFT, &[egui::Event::PointerMoved(at), wheel(2.0, egui::Modifiers::SHIFT)]);
@@ -538,7 +548,7 @@ mod tests {
// Alt turns Cancel into Reset.
assert!(h.query_by_label("Cancel").is_some() && h.query_by_label("Reset").is_none());
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("highlights".into(), json!([5, 5, 5]));
let tone = egui::Id::new(("adjust-dialog", "colorBalance")).with("cb-tone");
let tone = egui::Id::unique(("adjust-dialog", "colorBalance")).with("cb-tone");
h.ctx.data_mut(|d| d.insert_temp(tone, 2usize));
h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT));
h.run_steps(2);
@@ -616,13 +626,15 @@ mod tests {
PhotocraftApp::setup_context(&h.ctx, theme);
let id = open(h.state_mut(), "image.adjustments.curves").unwrap();
h.run_steps(4);
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).unwrap();
let window = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", id)))).unwrap();
let graph = h.ctx.data(|d| d.get_temp::<egui::Rect>(dialog_mem("curves").with("curves-graph"))).unwrap();
assert!(window.right() - graph.right() < 40.0, "blank right column: window {window:?}, graph {graph:?}");
assert!(graph.width() > 270.0, "keep the graph usable: {graph:?}");
assert!(window.contains_rect(graph));
for label in ["Channel:", "Input:", "Output:", "Preview", "OK", "Cancel"] {
assert!(window.contains_rect(h.get_by_label(crate::i18n::t(label)).rect()), "{label} inside the window");
// A caption and the field it names share the label; any of them is the control.
let node = h.query_all_by_label(crate::i18n::t(label)).next().unwrap_or_else(|| panic!("{label} in the dialog"));
assert!(window.contains_rect(node.rect()), "{label} inside the window");
}
}
}
@@ -636,7 +648,7 @@ mod tests {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
PhotocraftApp::setup_context(&h.ctx, Default::default());
let graph = |h: &Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::<egui::Rect>(dialog_mem("curves").with("curves-graph"))).unwrap();
let window = |h: &Harness<'_, PhotocraftApp>, id: u64| h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).unwrap();
let window = |h: &Harness<'_, PhotocraftApp>, id: u64| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", id)))).unwrap();
let id = open(h.state_mut(), "image.adjustments.curves").unwrap();
h.run_steps(4);
let (opened, small) = (window(&h, id), graph(&h));
@@ -659,7 +671,9 @@ mod tests {
assert!((big.width() - big.height()).abs() < 1.0, "still square: {big:?}");
assert!(wide.contains_rect(big) && wide.right() - big.right() < 40.0, "no blank column: window {wide:?}, graph {big:?}");
for label in ["Channel:", "Input:", "Output:", "Preview", "OK", "Cancel"] {
assert!(wide.contains_rect(h.get_by_label(crate::i18n::t(label)).rect()), "{label} inside the window");
for node in h.get_all_by_label(crate::i18n::t(label)) {
assert!(wide.contains_rect(node.rect()), "{label} label and control stay inside the window");
}
}
// Cancel, open again: the size it was left at.
h.get_by_label("Cancel").click();
@@ -676,16 +690,16 @@ mod tests {
fn curves_graph_never_pushes_the_dialog_off_a_short_window() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 700.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
PhotocraftApp::setup_context(&h.ctx, Default::default());
h.ctx.data_mut(|m| m.insert_persisted(egui::Id::new(("dialog-place", "image.adjustments.curves")).with("size"), egui::vec2(1200.0, 1200.0)));
h.ctx.data_mut(|m| m.insert_persisted(egui::Id::unique(("dialog-place", "image.adjustments.curves")).with("size"), egui::vec2(1200.0, 1200.0)));
let id = open(h.state_mut(), "image.adjustments.curves").unwrap();
h.run_steps(5);
let graph = h.ctx.data(|d| d.get_temp::<egui::Rect>(dialog_mem("curves").with("curves-graph"))).unwrap();
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).unwrap();
let window = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", id)))).unwrap();
assert!(graph.height() <= 700.0 - 360.0, "{graph:?}");
assert!(window.height() <= 700.0, "{window:?}");
assert!(window.contains_rect(h.get_by_label("OK").rect()));
// The size the window squeezed it to is not remembered: a larger window gets 1200 again.
let kept = h.ctx.data_mut(|m| m.get_persisted::<egui::Vec2>(egui::Id::new(("dialog-place", "image.adjustments.curves")).with("size")));
let kept = h.ctx.data_mut(|m| m.get_persisted::<egui::Vec2>(egui::Id::unique(("dialog-place", "image.adjustments.curves")).with("size")));
assert_eq!(kept, Some(egui::vec2(1200.0, 1200.0)));
// Dragged far wider, the dialog stops where the graph stops: no blank column.
let corner = window.max - egui::vec2(12.0, 12.0);
@@ -703,7 +717,7 @@ mod tests {
});
h.run_steps(5);
let graph = h.ctx.data(|d| d.get_temp::<egui::Rect>(dialog_mem("curves").with("curves-graph"))).unwrap();
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).unwrap();
let window = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", id)))).unwrap();
assert!(window.right() - graph.right() < 40.0, "no blank column: window {window:?}, graph {graph:?}");
}
@@ -764,7 +778,7 @@ mod tests {
fields.insert("pencil".into(), json!(true));
fields.insert("points".into(), json!((0..256).collect::<Vec<_>>()));
h.run_steps(4);
let graph = h.ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::new(("adjust-dialog", "curves")).with("curves-graph"))).unwrap();
let graph = h.ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique(("adjust-dialog", "curves")).with("curves-graph"))).unwrap();
let at = |i: f32, o: f32| egui::pos2(graph.left() + i / 255.0 * graph.width(), graph.bottom() - o / 255.0 * graph.height());
h.hover_at(at(64.0, 200.0));
h.run_steps(1);
@@ -794,7 +808,7 @@ mod tests {
let id = open(h.state_mut(), "image.adjustments.curves").unwrap();
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!([[0, 0], [128, 128], [255, 255]]));
h.run_steps(4);
let graph = h.ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::new(("adjust-dialog", "curves")).with("curves-graph"))).unwrap();
let graph = h.ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique(("adjust-dialog", "curves")).with("curves-graph"))).unwrap();
let a = egui::pos2(graph.left() + 128.0 / 255.0 * graph.width(), graph.bottom() - 128.0 / 255.0 * graph.height());
h.hover_at(a);
h.run_steps(1);
+16 -15
View File
@@ -309,9 +309,9 @@ fn channel_picker_opts(ui: &mut egui::Ui, id: egui::Id, opts: &[(usize, &str)],
let width = if opts.len() == 1 { 160.0 } else { 110.0 };
let key = format!("{id:?}-ch");
if translate {
widgets::dropdown(ui, &key, &mut ch, opts, width);
widgets::dropdown(ui, &key, text, &mut ch, opts, width);
} else {
widgets::dropdown_literal(ui, &key, &mut ch, opts, width);
widgets::dropdown_literal(ui, &key, text, &mut ch, opts, width);
}
});
ui.data_mut(|d| d.insert_temp(id, ch));
@@ -593,9 +593,9 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
let i = st.gesture.selected.filter(|i| *i < pts.len() && !pencil);
let (mut vi, mut vo) = i.map_or((0.0, 0.0), |i| (pts[i][0], pts[i][1]));
label(ui, tl!("Input:"));
let ri = ui.add_enabled_ui(i.is_some(), |ui| widgets::value_field(ui, &mut vi, 0.0..=255.0, "", 52.0)).inner;
let ri = ui.add_enabled_ui(i.is_some(), |ui| widgets::value_field(ui, &mut vi, 0.0..=255.0, "", 52.0).accessible_name(tl!("Input:"))).inner;
label(ui, tl!("Output:"));
let ro = ui.add_enabled_ui(i.is_some(), |ui| widgets::value_field(ui, &mut vo, 0.0..=255.0, "", 52.0)).inner;
let ro = ui.add_enabled_ui(i.is_some(), |ui| widgets::value_field(ui, &mut vo, 0.0..=255.0, "", 52.0).accessible_name(tl!("Output:"))).inner;
if let Some(i) = i
&& (ri.changed() || ro.changed())
&& curve_edit::move_to(&mut pts, i, [vi, vo])
@@ -768,11 +768,11 @@ fn levels(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
let fw = ((w - 16.0) / 3.0).min(70.0);
let (mut nb, mut ng, mut nw) = (l[0], l[1], l[2]);
let gap = ((w - 3.0 * fw) / 2.0 - 12.0).max(0.0);
let r0 = widgets::value_field(ui, &mut nb, 0.0..=253.0, "", fw);
let r0 = widgets::value_field(ui, &mut nb, 0.0..=253.0, "", fw).accessible_name(tl!("Black"));
ui.add_space(gap);
let r1 = widgets::value_field(ui, &mut ng, 0.01..=9.99, "", fw);
let r1 = widgets::value_field(ui, &mut ng, 0.01..=9.99, "", fw).accessible_name(tl!("Gamma"));
ui.add_space(gap);
let r2 = widgets::value_field(ui, &mut nw, 2.0..=255.0, "", fw);
let r2 = widgets::value_field(ui, &mut nw, 2.0..=255.0, "", fw).accessible_name(tl!("White"));
if r0.changed() {
l[0] = nb.round().min(l[2] - 2.0);
}
@@ -822,9 +822,9 @@ fn levels(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
ui.horizontal(|ui| {
let fw = ((w - 16.0) / 3.0).min(70.0);
let (mut a, mut c) = (l[3], l[4]);
let ra = widgets::value_field(ui, &mut a, 0.0..=255.0, "", fw);
let ra = widgets::value_field(ui, &mut a, 0.0..=255.0, "", fw).accessible_name(tl!("Black"));
ui.add_space((w - 2.0 * fw - 16.0).max(0.0));
let rc = widgets::value_field(ui, &mut c, 0.0..=255.0, "", fw);
let rc = widgets::value_field(ui, &mut c, 0.0..=255.0, "", fw).accessible_name(tl!("White"));
if ra.changed() {
l[3] = a.round();
}
@@ -930,7 +930,7 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
}
ui.horizontal(|ui| {
let opts: Vec<(usize, &str)> = std::iter::once((0, tl!("Master"))).chain(RANGE_LABELS.iter().enumerate().map(|(i, l)| (i + 1, *l))).collect();
ui.add_enabled_ui(!colorize, |ui| widgets::dropdown(ui, &format!("{range_id:?}"), &mut range, &opts, 120.0));
ui.add_enabled_ui(!colorize, |ui| widgets::dropdown(ui, &format!("{range_id:?}"), tl!("Range"), &mut range, &opts, 120.0));
});
ui.data_mut(|d| d.insert_temp(range_id, range));
let mut e = Edit::default();
@@ -1191,7 +1191,7 @@ fn photo_filter(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
if preset.is_none() {
opts.insert(0, ("custom".into(), "Custom"));
}
let changed = ui.add_enabled_ui(!use_color, |ui| widgets::dropdown(ui, &format!("{mode_id:?}-filter"), &mut sel, &opts, 170.0)).inner;
let changed = ui.add_enabled_ui(!use_color, |ui| widgets::dropdown(ui, &format!("{mode_id:?}-filter"), tl!("Filter:"), &mut sel, &opts, 170.0)).inner;
if changed && let Some(f) = PHOTO_FILTERS.iter().find(|f| f.0 == sel) {
c = f.2.map(|x| f32::from(x) / 255.0);
v["color"] = json!(adjust_params::hex(c));
@@ -1232,7 +1232,7 @@ fn channel_mixer(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
}
ui.horizontal(|ui| {
label(ui, tl!("Output Channel:"));
widgets::dropdown(ui, &format!("{out_id:?}"), &mut out, outs, 110.0);
widgets::dropdown(ui, &format!("{out_id:?}"), tl!("Output Channel:"), &mut out, outs, 110.0);
});
ui.data_mut(|d| d.insert_temp(out_id, out));
let key = if mono { "gray" } else { ["red", "green", "blue"][out] };
@@ -1341,7 +1341,7 @@ fn gradient_map_strip(ui: &mut egui::Ui, stops: &mut Vec<Stop>, cx: &EditorCx, s
let mut edit = Edit::default();
let w = ui.available_width().min(300.0);
let (outer, resp) = ui.allocate_exact_size(vec2(w, 44.0), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Gradient stops")));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Gradient stops")));
let bar = Rect::from_min_max(outer.min + vec2(8.0, 2.0), pos2(outer.right() - 8.0, outer.top() + 26.0));
let x_of = |loc: f32| bar.left() + loc * bar.width();
let loc_of = |x: f32| ((x - bar.left()) / bar.width().max(1.0)).clamp(0.0, 1.0);
@@ -1460,6 +1460,7 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
}
});
press.after(&combo.response);
let _ = combo.response.accessible_name(tl!("Preset:"));
if let Some(p) = picked {
stops = p;
selected = 0;
@@ -1488,7 +1489,7 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
ui.horizontal(|ui| {
label(ui, tl!("Location:"));
let mut loc = s.0 * 100.0;
let rl = widgets::value_field(ui, &mut loc, 0.0..=100.0, "%", 70.0);
let rl = widgets::value_field(ui, &mut loc, 0.0..=100.0, "%", 70.0).accessible_name(tl!("Location:"));
if rl.changed() {
location = Some(loc / 100.0);
changed = true;
@@ -1553,7 +1554,7 @@ pub fn swatches(app: &PhotocraftApp) -> [[f32; 3]; 2] {
/// Root of an adjustment layer's Properties editor view state in egui memory.
pub fn layer_mem(id: LayerId) -> egui::Id {
egui::Id::new(("adjust-layer", id.0))
egui::Id::unique(("adjust-layer", id.0))
}
/// Kinds whose Properties edits follow Photoshop's history and Reset (measured for Color Balance,
+23 -11
View File
@@ -5,7 +5,7 @@
use egui::{Pos2, Rect, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use egui_kittest::kittest::{NodeT, Queryable};
use photocraft_doc::{Adjustment, LayerContent, LayerId};
use serde_json::json;
@@ -88,12 +88,17 @@ fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) {
}
fn label_rect(h: &Harness<'_, PhotocraftApp>, text: &str) -> Option<Rect> {
// The last match: the Properties header repeats the kind's name above its controls.
h.query_all_by_label(text).last().map(|n| n.rect())
// The last caption: the Properties header repeats the kind's name above its controls.
// `get_by_label` hides a caption that names a control, so ask for the label node itself;
// a checkbox paints its caption as the control, so fall back to the last matching widget.
h.query_all(egui_kittest::kittest::by().label(text).include_labels())
.rfind(|n| n.accesskit_node().role() == egui::accesskit::Role::Label)
.map(|n| n.rect())
.or_else(|| h.query_all_by_label(text).last().map(|n| n.rect()))
}
fn curves_graph(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> Rect {
h.ctx.data(|d| d.get_temp::<Rect>(egui::Id::new(("adjust-layer", id.0)).with("curves-graph"))).expect("curves graph drawn")
h.ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique(("adjust-layer", id.0)).with("curves-graph"))).expect("curves graph drawn")
}
/// Issue #12: every adjustment kind, on a new document at 8/16/32 bits and in RGB, Grayscale,
@@ -182,6 +187,7 @@ fn color_lookup_picker_cycles_luts_with_the_mouse_wheel() {
assert!(h.state().live_adjust.is_some(), "arrow navigation selects a preview LUT");
for _ in 0..3 {
h.event(egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
@@ -327,7 +333,13 @@ fn wheel_at(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, notches: f32, mods: eg
h.event(egui::Event::ModifiersChanged(mods));
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, notches), phase: egui::TouchPhase::Move, modifiers: mods });
h.event(egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, notches),
phase: egui::TouchPhase::Move,
modifiers: mods,
});
h.run_steps(1);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(3);
@@ -580,7 +592,7 @@ fn gradient_map_stop_controls_fit_a_narrow_properties_panel_in_every_theme() {
return;
}
let cx = EditorCx {
mem: egui::Id::new("map-layout"),
mem: egui::Id::unique("map-layout"),
hist: None,
gray: false,
swatches: [[0.0; 3], [1.0; 3]],
@@ -789,7 +801,7 @@ fn editor_harness(kind: &'static str, params: serde_json::Value) -> Harness<'sta
let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(
move |ui, values: &mut serde_json::Value| {
let cx = EditorCx {
mem: egui::Id::new("precision"),
mem: egui::Id::unique("precision"),
hist: None,
gray: false,
swatches: [[0.0; 3], [1.0; 3]],
@@ -815,7 +827,7 @@ fn hue_saturation_bars_fill_the_panel_width() {
let mut h = Harness::builder().with_size(vec2(width, 500.0)).build_ui_state(
|ui, values: &mut serde_json::Value| {
let cx = EditorCx {
mem: egui::Id::new("hs"),
mem: egui::Id::unique("hs"),
hist: None,
gray: false,
swatches: [[0.0; 3], [1.0; 3]],
@@ -826,14 +838,14 @@ fn hue_saturation_bars_fill_the_panel_width() {
};
let avail = ui.available_width();
editor(ui, "hueSaturation", values, &cx);
ui.data_mut(|d| d.insert_temp(egui::Id::new("hs-avail"), avail));
ui.data_mut(|d| d.insert_temp(egui::Id::unique("hs-avail"), avail));
},
json!({}),
);
PhotocraftApp::setup_context(&h.ctx, Default::default());
h.run_steps(4);
let bars = h.ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("hs").with("hs-bars"))).expect("bars drawn");
let avail = h.ctx.data(|d| d.get_temp::<f32>(egui::Id::new("hs-avail"))).unwrap();
let bars = h.ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("hs").with("hs-bars"))).expect("bars drawn");
let avail = h.ctx.data(|d| d.get_temp::<f32>(egui::Id::unique("hs-avail"))).unwrap();
assert!((bars.width() - avail).abs() < 0.5, "{width}: bars {} of {avail}", bars.width());
}
}
+5 -5
View File
@@ -35,12 +35,12 @@ pub fn selective_color_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: La
Some((l, v)) if *l == id && v.get("reds").is_some() => v.clone(),
_ => committed,
};
let key = egui::Id::new(("selc-range", id.0));
let key = egui::Id::unique(("selc-range", id.0));
let mut range: usize = ui.data(|d| d.get_temp(key)).unwrap_or(0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Colors")).color(t.text_dim));
let opts: Vec<(usize, &str)> = RANGE_LABELS.iter().copied().enumerate().collect();
if widgets::dropdown(ui, &format!("selc-colors-{}", id.0), &mut range, &opts, 150.0) {
if widgets::dropdown(ui, &format!("selc-colors-{}", id.0), tl!("Color"), &mut range, &opts, 150.0) {
ui.data_mut(|d| d.insert_temp(key, range));
}
});
@@ -113,7 +113,7 @@ struct FolderScan {
fn folder_luts(app: &PhotocraftApp, ctx: &egui::Context) -> FolderLuts {
let folder = app.session.prefs().file_handling.lut_folder.clone();
let now = ctx.input(|input| input.time);
let key = egui::Id::new("color-lookup-folder");
let key = egui::Id::unique("color-lookup-folder");
let mut scan = ctx.data(|data| data.get_temp::<FolderScan>(key)).unwrap_or_default();
if let Some(done) = scan.pending.as_ref().and_then(|p| p.lock().unwrap_or_else(PoisonError::into_inner).take()) {
scan.luts = done;
@@ -197,7 +197,7 @@ pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: Layer
let mut sel = current.clone();
let mut params: Option<Value> = None;
let mut browse = false;
let cycle_key = egui::Id::new(("color-lookup-cycle", id.0));
let cycle_key = egui::Id::unique(("color-lookup-cycle", id.0));
let cycle_input = ui.input(|input| {
input.raw.events.iter().any(|event| {
matches!(event, egui::Event::MouseWheel { .. })
@@ -207,7 +207,7 @@ pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: Layer
let mut cycling_preview = false;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("3D LUT File")).color(t.text_dim));
let (chosen, _) = widgets::dropdown_wheel_hovered(ui, &format!("clrl-{}", id.0), &mut sel, &opts, 170.0);
let (chosen, _) = widgets::dropdown_wheel_hovered(ui, &format!("clrl-{}", id.0), tl!("Color"), &mut sel, &opts, 170.0);
if !chosen.is_empty() {
if cycle_input {
// ponytail: wheel previews cover the built-ins only; a folder LUT would be re-read
+13 -12
View File
@@ -530,7 +530,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
if !m.count_groups.is_empty() {
let mut gi = m.active_count_group.min(m.count_groups.len() - 1);
let opts: Vec<(usize, &str)> = m.count_groups.iter().enumerate().map(|(i, g)| (i, g.name.as_str())).collect();
if crate::widgets::dropdown(ui, "count-group", &mut gi, &opts, 140.0) {
if crate::widgets::dropdown(ui, "count-group", tl!("Group"), &mut gi, &opts, 140.0) {
let _ = app.run("count.setGroup", json!({"group": gi, "active": true}));
}
let g = &m.count_groups[gi];
@@ -563,9 +563,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
crate::widgets::vline(ui, 22.0);
let (mut ms, mut ls) = (g.marker_size as f32, g.label_size as f32);
ui.label(RichText::new(tl!("Marker Size")).color(t.text_dim).size(11.0));
let a = crate::widgets::value_field(ui, &mut ms, 1.0..=10.0, "", 40.0).changed();
let a = crate::widgets::value_field(ui, &mut ms, 1.0..=10.0, "", 40.0).accessible_name(tl!("Marker Size")).changed();
ui.label(RichText::new(tl!("Label Size")).color(t.text_dim).size(11.0));
let b = crate::widgets::value_field(ui, &mut ls, 8.0..=72.0, "", 44.0).changed();
let b = crate::widgets::value_field(ui, &mut ls, 8.0..=72.0, "", 44.0).accessible_name(tl!("Label Size")).changed();
if a || b {
let _ = app.run("count.setGroup", count_size_params(ms, ls));
}
@@ -574,8 +574,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
}
Tool::Note => {
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(tl!("Author:")).color(t.text_dim).size(11.0));
ui.add(egui::TextEdit::singleline(&mut app.ui.analysis.note_author).desired_width(120.0));
let author_label = ui.label(RichText::new(tl!("Author:")).color(t.text_dim).size(11.0));
ui.add(egui::TextEdit::singleline(&mut app.ui.analysis.note_author).desired_width(120.0)).labelled_by(author_label.id);
ui.label(RichText::new(tl!("Color:")).color(t.text_dim).size(11.0));
let rgb = app.ui.analysis.note_color;
if crate::widgets::color_swatch_button(ui, color32(rgb), tl!("Color")).clicked() {
@@ -653,7 +653,7 @@ pub(crate) fn panel_window(app: &PhotocraftApp, ctx: &egui::Context, id: &str, t
let t = Tokens::get(ctx);
let canvas = app.last_canvas_rect;
egui::Window::new(title)
.id(egui::Id::new(id))
.id(egui::Id::unique(id))
.title_bar(false)
.resizable(false)
.movable(!app.session.prefs().workspace_locked)
@@ -789,7 +789,7 @@ fn notes_panel(app: &mut PhotocraftApp, ctx: &egui::Context) {
let note = &notes[i];
ui.label(RichText::new(if note.author.is_empty() { tl!("No author").to_string() } else { note.author.clone() }).color(t.text_dim).size(11.0));
let mut text = note.text.clone();
if ui.add(egui::TextEdit::multiline(&mut text).desired_rows(6).desired_width(f32::INFINITY)).changed() {
if ui.add(egui::TextEdit::multiline(&mut text).desired_rows(6).desired_width(f32::INFINITY)).accessible_name(tl!("Notes")).changed() {
edit = Some(json!({"index": i, "text": text, "coalesce": format!("note-{i}")}));
}
}
@@ -837,7 +837,7 @@ fn num_field(ui: &mut egui::Ui, f: &mut Map<String, Value>, key: &str, label: &s
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 18.0), egui::Label::new(RichText::new(tl!(&label)).color(t.text_dim).size(11.0)));
let mut v = f.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32;
if crate::widgets::value_field(ui, &mut v, range, suffix, 80.0).changed() {
if crate::widgets::value_field(ui, &mut v, range, suffix, 80.0).accessible_name(tl!(label)).changed() {
f.insert(key.into(), json!(v));
}
});
@@ -849,7 +849,7 @@ fn choice(ui: &mut egui::Ui, f: &mut Map<String, Value>, key: &str, label: &str,
ui.add_sized(vec2(110.0, 18.0), egui::Label::new(RichText::new(tl!(&label)).color(t.text_dim).size(11.0)));
let mut cur = f.get(key).and_then(Value::as_str).unwrap_or(opts[0].0).to_string();
let list: Vec<(String, &str)> = opts.iter().map(|(k, l)| (k.to_string(), *l)).collect();
if crate::widgets::dropdown(ui, &format!("an-{key}"), &mut cur, &list, 120.0) {
if crate::widgets::dropdown(ui, &format!("an-{key}"), tl!(label), &mut cur, &list, 120.0) {
f.insert(key.into(), json!(cur));
}
});
@@ -887,7 +887,7 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) {
};
let mut result: Option<bool> = None;
egui::Window::new(title)
.id(egui::Id::new("analysis-dialog"))
.id(egui::Id::unique("analysis-dialog"))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
@@ -904,9 +904,10 @@ fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) {
num_field(ui, &mut f, "pixelLength", tl!("Pixel Length:"), 0.001..=1e7, "");
num_field(ui, &mut f, "logicalLength", tl!("Logical Length:"), 0.0001..=1e7, "");
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 18.0), egui::Label::new(RichText::new(tl!("Logical Units:")).color(t.text_dim).size(11.0)));
let units_label =
ui.add_sized(vec2(110.0, 18.0), egui::Label::new(RichText::new(tl!("Logical Units:")).color(t.text_dim).size(11.0)));
let mut u = f.get("units").and_then(Value::as_str).unwrap_or("pixels").to_string();
if ui.add(egui::TextEdit::singleline(&mut u).desired_width(80.0)).changed() {
if ui.add(egui::TextEdit::singleline(&mut u).desired_width(80.0)).labelled_by(units_label.id).changed() {
f.insert("units".into(), json!(u));
}
});
+1 -1
View File
@@ -63,7 +63,7 @@ pub fn gesture(ctx: &Context) -> Option<String> {
}
fn focus_key() -> Id {
Id::new("apply-color-focus")
Id::unique("apply-color-focus")
}
/// Counts the changes of keyboard focus, so two values typed into one field, each after
+3 -3
View File
@@ -304,14 +304,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
let mut preset = preset.unwrap_or_default();
let mut opts: Vec<(String, &str)> = vec![(String::new(), tl!("Custom"))];
opts.extend(photocraft_engine::artboard_cmds::PRESETS.iter().map(|(n, _, _)| (n.to_string(), *n)));
if crate::widgets::dropdown(ui, "artboard-tool-preset", &mut preset, &opts, 150.0) && !preset.is_empty() {
if crate::widgets::dropdown(ui, "artboard-tool-preset", tl!("Preset"), &mut preset, &opts, 150.0) && !preset.is_empty() {
edit = Some(json!({"layer": id.0, "preset": preset}));
}
let (mut w, mut h) = ((r[2] - r[0]) as f32, (r[3] - r[1]) as f32);
ui.label(egui::RichText::new(tl!("W")).color(t.text_dim));
let cw = crate::widgets::value_field(ui, &mut w, 1.0..=300_000.0, "px", 64.0).changed();
let cw = crate::widgets::value_field(ui, &mut w, 1.0..=300_000.0, "px", 64.0).accessible_name(tl!("Width")).changed();
ui.label(egui::RichText::new(tl!("H")).color(t.text_dim));
let ch = crate::widgets::value_field(ui, &mut h, 1.0..=300_000.0, "px", 64.0).changed();
let ch = crate::widgets::value_field(ui, &mut h, 1.0..=300_000.0, "px", 64.0).accessible_name(tl!("Height")).changed();
if cw || ch {
edit = Some(json!({"layer": id.0, "width": w.round(), "height": h.round(), "coalesce": format!("artboard-tool-wh-{}", id.0)}));
}
+10 -8
View File
@@ -141,27 +141,29 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
opts.extend(photocraft_engine::artboard_cmds::PRESETS.iter().map(|(n, _, _)| (n.to_string(), *n)));
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Size")).color(t.text_dim).size(12.0));
if crate::widgets::dropdown(ui, &key("preset"), &mut preset, &opts, 150.0) && !preset.is_empty() {
if crate::widgets::dropdown(ui, &key("preset"), tl!("Preset"), &mut preset, &opts, 150.0) && !preset.is_empty() {
edit = Some(json!({"layer": layer.id.0, "preset": preset}));
}
});
let num = |ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>| -> bool {
// The visible label and the accessible name differ for X and Y ("X" alone does not say
// position), so the name comes from the call site.
let num = |ui: &mut egui::Ui, label: &str, name: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>| -> bool {
ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim).size(12.0));
crate::widgets::value_field(ui, v, range, "px", 64.0).changed()
crate::widgets::value_field(ui, v, range, "px", 64.0).accessible_name(tl!(name)).changed()
};
let r = a.rect;
ui.horizontal(|ui| {
let (mut w, mut h) = (r.width() as f32, r.height() as f32);
let cw = num(ui, "W", &mut w, 1.0..=300_000.0);
let ch = num(ui, "H", &mut h, 1.0..=300_000.0);
let cw = num(ui, "W", "Width", &mut w, 1.0..=300_000.0);
let ch = num(ui, "H", "Height", &mut h, 1.0..=300_000.0);
if cw || ch {
edit = Some(json!({"layer": layer.id.0, "width": w.round(), "height": h.round(), "coalesce": key("wh")}));
}
});
ui.horizontal(|ui| {
let (mut x, mut y) = (r.x0 as f32, r.y0 as f32);
let cx = num(ui, "X", &mut x, -300_000.0..=300_000.0);
let cy = num(ui, "Y", &mut y, -300_000.0..=300_000.0);
let cx = num(ui, "X", "X", &mut x, -300_000.0..=300_000.0);
let cy = num(ui, "Y", "Y", &mut y, -300_000.0..=300_000.0);
if cx || cy {
edit = Some(json!({"layer": layer.id.0, "x": x.round(), "y": y.round(), "coalesce": key("xy")}));
}
@@ -178,7 +180,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
("transparent".to_string(), tl!("Transparent")),
("custom".to_string(), tl!("Other…")),
];
if crate::widgets::dropdown(ui, &key("bg"), &mut bg, &opts, 120.0) {
if crate::widgets::dropdown(ui, &key("bg"), tl!("Background"), &mut bg, &opts, 120.0) {
let mut params = json!({"layer": layer.id.0, "background": bg});
if bg == "custom" {
let hex = match a.background {
+32
View File
@@ -456,6 +456,36 @@ fn spawn(app: &mut PhotocraftApp, ctx: &egui::Context, s: Spawn) {
);
}
/// A test can hold the worker after a batch: a real worker's speed is not reproducible, and the
/// edit-during-a-redraw test needs the redraw to be certainly still running.
#[cfg(test)]
pub(crate) mod test_hold {
use std::sync::atomic::{AtomicBool, Ordering};
static HELD: AtomicBool = AtomicBool::new(false);
/// Holds every redraw worker after its next batch until this guard drops.
pub(crate) struct Hold;
pub(crate) fn hold() -> Hold {
HELD.store(true, Ordering::SeqCst);
Hold
}
impl Drop for Hold {
fn drop(&mut self) {
HELD.store(false, Ordering::SeqCst);
}
}
/// Called by the worker between batches while a test holds it.
pub(crate) fn wait() {
while HELD.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
}
/// The worker: composite `todo`'s blocks of `doc` in batches and hand each over, until done or
/// stopped.
#[allow(clippy::too_many_arguments)]
@@ -487,6 +517,8 @@ fn draw(
}
ctx.request_repaint();
}
#[cfg(test)]
test_hold::wait();
}
}
@@ -210,7 +210,10 @@ fn a_full_cpu_refresh_draws_in_the_background_with_the_synchronous_pixels() {
fn an_edit_during_a_redraw_is_not_overwritten_by_blocks_drawn_before_it() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut g) = gpu_app() else { return };
// 4×3 blocks: the worker can't hand them all over before the next frame.
// Hold the worker after its first batch: how fast it composites is not reproducible (a fast
// machine finished all 4×3 blocks before the edit's frame on FreeBSD), and the test needs the
// redraw to still be running when the edit lands. The hold is released before `settle`.
let hold = test_hold::hold();
let doc = cpu_only_document(g.harness.state_mut(), 4096, 3072);
g.harness.step();
assert!(g.harness.state().redraws.contains_key(&doc));
@@ -224,6 +227,7 @@ fn an_edit_during_a_redraw_is_not_overwritten_by_blocks_drawn_before_it() {
g.harness.step();
assert_eq!(g.harness.state().perf.last_refresh, "rect", "the fill's damage was drawn synchronously");
assert!(g.harness.state().redraws.contains_key(&doc), "the redraw goes on");
drop(hold);
settle(&mut g);
assert_matches_synchronous(&mut g, doc);
}
+10 -8
View File
@@ -70,17 +70,19 @@ fn label(ui: &mut egui::Ui, text: &str) {
/// A value stored in per-inch units, edited in per-inch or per-centimeter ones: `field` holds the
/// per-inch value, `unit` the view's unit (`per_cm` when its value is per centimeter).
fn per_unit_row(ui: &mut egui::Ui, f: &mut Map<String, Value>, field: &str, range: (f32, f32), default: f32, unit: &str, units: [(&str, &str); 2]) {
// The explicit accessible name accompanies the existing numeric-field parameters.
#[expect(clippy::too_many_arguments)]
fn per_unit_row(ui: &mut egui::Ui, f: &mut Map<String, Value>, field: &str, name: &str, range: (f32, f32), default: f32, unit: &str, units: [(&str, &str); 2]) {
let per_inch = num(f, field, default);
let mut cur = text(f, unit, units[0].0);
let per_cm = cur == units[1].0;
let k = if per_cm { CM } else { 1.0 };
let mut shown = per_inch / k;
if crate::widgets::value_field(ui, &mut shown, range.0 / k..=range.1 / k, "", 70.0).changed() {
if crate::widgets::value_field(ui, &mut shown, range.0 / k..=range.1 / k, "", 70.0).accessible_name(name).changed() {
f.insert(field.into(), json!(((shown * k).clamp(range.0, range.1) * 1000.0).round() / 1000.0));
}
let opts: Vec<(String, &str)> = units.iter().map(|(id, l)| (id.to_string(), *l)).collect();
if crate::widgets::dropdown(ui, &format!("bitmap-{unit}"), &mut cur, &opts, 150.0) {
if crate::widgets::dropdown(ui, &format!("bitmap-{unit}"), tl!("Units"), &mut cur, &opts, 150.0) {
f.insert(unit.into(), json!(cur));
}
}
@@ -102,7 +104,7 @@ pub(crate) fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, V
ui.horizontal(|ui| {
label(ui, tl!("Output:"));
let input = num(f, "__inputResolution", 72.0);
per_unit_row(ui, f, "resolution", RESOLUTION, input, "__resUnit", res_units);
per_unit_row(ui, f, "resolution", tl!("Resolution"), RESOLUTION, input, "__resUnit", res_units);
});
ui.add_space(4.0);
@@ -112,7 +114,7 @@ pub(crate) fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, V
ui.horizontal(|ui| {
label(ui, tl!("Use:"));
let opts: Vec<(String, &str)> = METHODS.iter().map(|(id, l)| (id.to_string(), tl!(l))).collect();
if crate::widgets::dropdown(ui, "bitmap-method", &mut method, &opts, 190.0) {
if crate::widgets::dropdown(ui, "bitmap-method", tl!("Method"), &mut method, &opts, 190.0) {
f.insert("method".into(), json!(method));
}
});
@@ -135,12 +137,12 @@ pub(crate) fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, V
crate::widgets::hairline(ui);
ui.horizontal(|ui| {
label(ui, tl!("Frequency:"));
per_unit_row(ui, f, "frequency", FREQUENCY, 53.0, "__freqUnit", [("lpi", tl!("Lines/Inch")), ("lpcm", tl!("Lines/Centimeter"))]);
per_unit_row(ui, f, "frequency", tl!("Frequency"), FREQUENCY, 53.0, "__freqUnit", [("lpi", tl!("Lines/Inch")), ("lpcm", tl!("Lines/Centimeter"))]);
});
ui.horizontal(|ui| {
label(ui, tl!("Angle:"));
let mut angle = num(f, "angle", 45.0);
if crate::widgets::value_field(ui, &mut angle, -180.0..=180.0, "°", 70.0).changed() {
if crate::widgets::value_field(ui, &mut angle, -180.0..=180.0, "°", 70.0).accessible_name(tl!("Angle:")).changed() {
f.insert("angle".into(), json!((angle.clamp(-180.0, 180.0) * 10.0).round() / 10.0));
}
});
@@ -148,7 +150,7 @@ pub(crate) fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, V
label(ui, tl!("Shape:"));
let mut shape = text(f, "shape", "round");
let opts: Vec<(String, &str)> = SHAPES.iter().map(|(id, l)| (id.to_string(), tl!(l))).collect();
if crate::widgets::dropdown(ui, "bitmap-shape", &mut shape, &opts, 150.0) {
if crate::widgets::dropdown(ui, "bitmap-shape", tl!("Shape"), &mut shape, &opts, 150.0) {
f.insert("shape".into(), json!(shape));
}
});
@@ -48,6 +48,7 @@ fn wheel(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, lines: f32) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, lines),
phase: egui::TouchPhase::Move,
+2 -2
View File
@@ -22,7 +22,7 @@ fn decode() -> egui::ColorImage {
/// The icon texture, uploaded on first use and kept in the context.
fn texture(ctx: &Context) -> TextureHandle {
let id = Id::new("photocraft-brand-mark");
let id = Id::unique("photocraft-brand-mark");
if let Some(tex) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
return tex;
}
@@ -34,7 +34,7 @@ fn texture(ctx: &Context) -> TextureHandle {
/// Where the mark was painted this frame (tests and screenshots).
fn rect_id() -> Id {
Id::new("photocraft-brand-mark-rect")
Id::unique("photocraft-brand-mark-rect")
}
/// Paint the mark into `r` (square).
+1 -1
View File
@@ -123,7 +123,7 @@ pub(crate) fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> O
if matches!(brush.tip, TipShape::Round) && brush.angle == 0.0 && brush.roundness >= 1.0 {
return None;
}
let id = egui::Id::new("photocraft-tip-outline");
let id = egui::Id::unique("photocraft-tip-outline");
if let Some(c) = ctx.data(|d| d.get_temp::<Cached>(id))
&& c.key.matches(brush, full)
{
+10 -10
View File
@@ -192,7 +192,7 @@ fn gesture(ctx: &egui::Context) -> u64 {
let frame = ctx.cumulative_pass_nr();
let pressed = ctx.input(|i| i.pointer.any_pressed());
ctx.data_mut(|d| {
let g = d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE));
let g = d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::unique(GESTURE));
if pressed && g.1 != frame {
g.0 += 1;
g.1 = frame;
@@ -207,7 +207,7 @@ fn gesture(ctx: &egui::Context) -> u64 {
pub fn commit_gesture(app: &mut PhotocraftApp, ctx: &egui::Context, before: &BrushSettings, after: &BrushSettings) {
let g = gesture(ctx);
if send(app, before, after, Some(format!("brush-ui:{g}"))) && !ctx.input(|i| i.pointer.any_down()) {
ctx.data_mut(|d| d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE)).0 += 1);
ctx.data_mut(|d| d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::unique(GESTURE)).0 += 1);
}
}
@@ -274,9 +274,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
// Named for accessibility and for tests and agents that look sections up by name.
if toggle_only {
let checked = section_flag(b, i).is_some_and(|flag| *flag);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, tl!(*name)));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, checked, tl!(*name)));
} else {
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, tl!(*name)));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, tl!(*name)));
}
if sel {
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
@@ -289,9 +289,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
// A checkbox-only row is one accessible checkbox across its whole width. A
// controls section keeps its separate enable box and selectable label.
let box_resp = (!toggle_only).then(|| {
let resp = ui.interact(br, ui.id().with(("brush-sec-box", i)), Sense::click());
let resp = ui.interact(br, ui.scope_id().with(("brush-sec-box", i)), Sense::click());
resp.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, *flag, crate::i18n::fmt(tl!("Enable {name}"), &[("name", tl!(*name))]))
egui::WidgetInfo::selected(egui::Role::CheckBox, true, *flag, crate::i18n::fmt(tl!("Enable {name}"), &[("name", tl!(*name))]))
});
resp
});
@@ -323,8 +323,8 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
let mut lock_clicked = false;
if let Some(lock) = section_lock(b, i) {
let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0));
let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click());
lresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), *lock, format!("{}: {}", tl!("Lock"), tl!(*name))));
let lresp = ui.interact(lr, ui.scope_id().with(("brush-sec-lock", i)), Sense::click());
lresp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), *lock, format!("{}: {}", tl!("Lock"), tl!(*name))));
// Like Photoshop: lock icons stay visible on every lockable section row.
let tint = if *lock { t.text } else { t.text_faint };
icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint);
@@ -402,7 +402,7 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let current = app.session.tools.current_preset.clone().filter(|n| app.session.tools.presets.iter().any(|p| p.name.eq_ignore_ascii_case(n)));
let update =
ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "check", 24.0, false, tl!("Update the current brush with these settings"))).inner;
update.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, current.is_some(), "Update the current brush with these settings"));
update.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, current.is_some(), "Update the current brush with these settings"));
if update.clicked() {
run_or_status(app, "brush.presets.update", json!({ "brush": serde_json::to_value(&b).unwrap_or(Value::Null) }));
}
@@ -511,7 +511,7 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
let canvas = app.last_canvas_rect;
let mut open = true;
egui::Window::new(tl!("Brush Settings"))
.id(egui::Id::new("brush-settings"))
.id(egui::Id::unique("brush-settings"))
.title_bar(false)
.resizable(false)
.frame(frame)
+14 -6
View File
@@ -1,5 +1,6 @@
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use photocraft_engine::paint::{Control, GrayTile, MaskMode, Pattern, PatternStyle, TipShape};
use super::*;
@@ -12,6 +13,14 @@ fn last_journal(app: &PhotocraftApp) -> Option<(String, Value)> {
app.session.journal.last().cloned()
}
/// The visible options caption, not the numeric field it names.
fn caption_rect(h: &Harness<'_, PhotocraftApp>, text: &str) -> egui::Rect {
h.query_all(egui_kittest::kittest::by().label(text).include_labels())
.find(|n| n.accesskit_node().role() == egui::accesskit::Role::Label)
.unwrap_or_else(|| panic!("the `{text}` caption"))
.rect()
}
#[test]
fn sections_with_boxes_have_flags() {
let mut b = BrushSettings::default();
@@ -420,11 +429,10 @@ fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftA
#[test]
fn clone_stamp_opacity_and_flow_are_journaled_per_gesture() {
use egui_kittest::kittest::Queryable;
let mut h = options_bar_harness(crate::state::Tool::CloneStamp);
for (label, field) in [("Opacity", "opacity"), ("Flow", "flow")] {
let n = h.state().session.journal.len();
let rect = h.get_by_label(label).rect();
let rect = caption_rect(&h, label);
let start = egui::pos2(rect.right() + 40.0, rect.center().y);
h.hover_at(start);
h.run();
@@ -464,7 +472,7 @@ fn options_bar_edits_are_one_set_brush_per_gesture() {
h.run_steps(3);
assert_eq!(h.state().session.journal.len(), n, "showing the bar issues nothing");
// Drag the Opacity field left: many frames, one journal entry.
let label = h.get_by_label("Opacity").rect();
let label = caption_rect(&h, "Opacity");
let start = egui::pos2(label.right() + 40.0, label.center().y);
h.hover_at(start);
h.run();
@@ -753,8 +761,9 @@ fn transfer_mixer_edits_use_the_brush_command_and_survive_real_tool_swaps() {
a.ui.tool = Tool::MixerBrush;
crate::paint_mouse::sync_tool_brush(a);
h.run_steps(2);
h.hover_at(h.get_by_label("Opacity Jitter").rect().center());
h.hover_at(caption_rect(&h, "Opacity Jitter").center());
h.event(egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -250.0),
phase: egui::TouchPhase::Move,
@@ -1003,12 +1012,11 @@ fn checkbox_only_rows_are_accessible_checkboxes_and_accept_space() {
#[test]
fn editing_the_options_bar_amount_reenables_smoothing() {
use egui_kittest::kittest::Queryable;
let mut h = options_bar_harness(crate::state::Tool::Brush);
h.state_mut().run("tools.setBrush", json!({"brush": {"smoothing": {"enabled": false, "amount": 0.4}}})).unwrap();
h.run_steps(3);
let n = h.state().session.journal.len();
let label = h.get_by_label("Smoothing").rect();
let label = caption_rect(&h, "Smoothing");
let at = egui::pos2(label.right() + 25.0, label.center().y);
h.hover_at(at);
h.run_steps(2);
+6 -6
View File
@@ -76,7 +76,7 @@ impl Pick {
}
pub(crate) fn picker_id() -> egui::Id {
egui::Id::new("canvas-brush-picker")
egui::Id::unique("canvas-brush-picker")
}
/// All opening paths protect the same frame, including the control channel and options bar.
@@ -88,7 +88,7 @@ pub(crate) fn open(ui: &mut crate::state::UiState, ctx: &egui::Context, at: [f32
/// The options-bar brush chip that shows and hides the picker. A press on it isn't an outside
/// press that closes the picker: the chip's click toggles it.
pub fn chip_id() -> egui::Id {
egui::Id::new("brush-preset-chip")
egui::Id::unique("brush-preset-chip")
}
/// Is screen point `p` on the options-bar brush chip?
@@ -197,7 +197,7 @@ pub fn settings_toggle(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
/// An icon button's accessible name (icon buttons have only a tooltip).
pub(crate) fn named(resp: egui::Response, label: &str) -> egui::Response {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(label)));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(label)));
resp
}
@@ -234,7 +234,7 @@ pub fn body(
ui.label(RichText::new(tl!("Size")).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut size = b.size;
if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).changed() {
if widgets::value_field(ui, &mut size, 1.0..=MAX_BRUSH_SIZE, "px", 72.0).accessible_name(tl!("Size")).changed() {
b.size = size.round().clamp(1.0, MAX_BRUSH_SIZE);
}
});
@@ -260,7 +260,7 @@ pub fn body(
ui.horizontal(|ui| {
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
ui.add_space(20.0);
widgets::text_field(ui, ui.id().with("brush-picker-search"), &mut st.filter, tl!("Search Brushes"), (size.x - 118.0).max(60.0), false);
widgets::text_field(ui, ui.scope_id().with("brush-picker-search"), &mut st.filter, tl!("Search Brushes"), (size.x - 118.0).max(60.0), false);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
gear_menu(ui, current, presets, st, &mut picks);
let new_tip = "Create new brush preset from the current settings";
@@ -330,7 +330,7 @@ pub fn clamp_size(size: [f32; 2], screen: egui::Vec2) -> egui::Vec2 {
pub fn resize_grip(ui: &mut egui::Ui, frame: egui::Rect, content: egui::Vec2) -> Option<egui::Vec2> {
let t = Tokens::get(ui.ctx());
let grip = egui::Rect::from_min_size(frame.right_bottom() - vec2(16.0, 16.0), vec2(16.0, 16.0));
let resp = ui.interact(grip, ui.id().with("brush-picker-resize"), egui::Sense::drag());
let resp = ui.interact(grip, ui.scope_id().with("brush-picker-resize"), egui::Sense::drag());
dismiss_popup(&resp);
let corner = grip.right_bottom() - vec2(3.0, 3.0);
for i in 0..3 {
+2 -2
View File
@@ -96,8 +96,8 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>,
ui.horizontal(|ui| {
ui.label(tl!("Name:"));
let mut text = f.get("name").and_then(Value::as_str).unwrap_or("").to_string();
let id = ui.id().with("new-brush-preset-name");
let r = widgets::text_field(ui, id, &mut text, "", 220.0, false);
let id = ui.scope_id().with("new-brush-preset-name");
let r = widgets::text_field(ui, id, &mut text, "", 220.0, false).accessible_name(tl!("Name:"));
// Photoshop opens on the name, all of it selected, so typing replaces it.
let once = id.with("focused");
if !ui.ctx().data(|d| d.get_temp::<bool>(once).unwrap_or(false)) {
+1 -1
View File
@@ -220,7 +220,7 @@ fn upload(ctx: &egui::Context, name: &str, w: u32, h: u32, rgba: &[u8]) -> egui:
}
fn cache_id() -> egui::Id {
egui::Id::new("brush-preview-cache")
egui::Id::unique("brush-preview-cache")
}
/// Run `f` on the context's preview cache. The cache is taken out of egui's memory while `f` runs,
+2 -2
View File
@@ -320,7 +320,7 @@ mod tests {
assert!(h.state().brush_resize.is_some());
assert!(h.state().session.tools.brush.size > size0, "dragging right grew the brush");
assert!(h.state().session.tools.brush.hardness > 0.2, "dragging down hardened it");
assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("brush-resize-readout"))).is_some(), "the readout shows mid-drag");
assert!(h.ctx.memory(|m| m.area_rect(egui::Id::unique("brush-resize-readout"))).is_some(), "the readout shows mid-drag");
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the pointer hides");
press(&mut h, c + vec2(60.0, 48.0), false);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
@@ -382,7 +382,7 @@ mod tests {
assert!(r.secondary);
assert!(h.state().session.tools.brush.size > size0, "right grows the brush");
assert!(h.state().session.tools.brush.hardness > 0.2, "down hardens it");
assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("brush-resize-readout"))).is_some(), "the readout shows");
assert!(h.ctx.memory(|m| m.area_rect(egui::Id::unique("brush-resize-readout"))).is_some(), "the readout shows");
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None);
button(&mut h, end, PointerButton::Secondary, false, Modifiers::ALT);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
+24 -16
View File
@@ -76,7 +76,7 @@ pub(crate) fn takes_curve(control: Control, angle: bool) -> bool {
/// egui memory key of the open/closed state of a dynamic's curve editor.
pub(crate) fn curve_open_id(id: &str) -> egui::Id {
egui::Id::new(("brush-curve-open", id))
egui::Id::unique(("brush-curve-open", id))
}
/// `Control: [Off ▾] [25] [~]`: the controller pop-up, Fade's step count and the button that
@@ -88,10 +88,10 @@ fn control_row(ui: &mut egui::Ui, id: &str, d: &mut Dynamic, angle: bool) {
ui.horizontal(|ui| {
dim(ui, tl!("Control:"));
let n = if angle || matches!(d.control, Control::InitialDirection | Control::Direction) { 8 } else { 6 };
widgets::dropdown(ui, id, &mut d.control, &CONTROLS[..n], 130.0);
widgets::dropdown(ui, id, tl!("Control:"), &mut d.control, &CONTROLS[..n], 130.0);
if d.control == Control::Fade {
let mut steps = d.fade_steps as f32;
if widgets::value_field(ui, &mut steps, 1.0..=9999.0, "", 56.0).on_hover_text(tl!("Fade steps")).changed() {
if widgets::value_field(ui, &mut steps, 1.0..=9999.0, "", 56.0).accessible_name(tl!("Fade steps")).on_hover_text(tl!("Fade steps")).changed() {
d.fade_steps = steps.round().clamp(1.0, 9999.0) as u32;
}
}
@@ -101,7 +101,7 @@ fn control_row(ui: &mut egui::Ui, id: &str, d: &mut Dynamic, angle: bool) {
});
if open && takes_curve(d.control, angle) {
let mut points = d.curve.points();
if crate::pressure_curve_ui::edit_points(ui, egui::Id::new(("brush-curve-rect", id)), &mut points) {
if crate::pressure_curve_ui::edit_points(ui, egui::Id::unique(("brush-curve-rect", id)), &mut points) {
d.curve = paint::ResponseCurve::new(&points);
}
}
@@ -132,7 +132,7 @@ fn size_row(ui: &mut egui::Ui, label: &str, size: &mut f32, max: f32, restore: O
ui.label(RichText::new(tl!(label)).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut s = *size;
if widgets::value_field(ui, &mut s, 1.0..=max, "px", 74.0).changed() {
if widgets::value_field(ui, &mut s, 1.0..=max, "px", 74.0).accessible_name(tl!(label)).changed() {
*size = s.round().clamp(1.0, max);
}
if let Some(orig) = restore
@@ -332,7 +332,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset],
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Angle:")));
let mut a = b.angle;
if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).changed() {
if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).accessible_name(tl!("Angle:")).changed() {
b.angle = a.round().clamp(-180.0, 180.0);
}
});
@@ -340,7 +340,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset],
ui.horizontal(|ui| {
ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Roundness:")));
let mut r = (b.roundness * 100.0).round();
if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).changed() {
if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).accessible_name(tl!("Roundness:")).changed() {
b.roundness = (r.round() / 100.0).clamp(0.01, 1.0);
}
});
@@ -454,7 +454,7 @@ fn texture(ui: &mut egui::Ui, b: &mut BrushSettings) {
if style.is_none() {
opts.push((None, tl!("Custom")));
}
if widgets::dropdown(ui, "brush-pattern", &mut style, &opts, 110.0)
if widgets::dropdown(ui, "brush-pattern", tl!("Pattern"), &mut style, &opts, 110.0)
&& let Some(s) = style
{
let size = match &tx.pattern {
@@ -488,7 +488,7 @@ fn texture(ui: &mut egui::Ui, b: &mut BrushSettings) {
ui.add_space(4.0);
ui.horizontal(|ui| {
dim(ui, tl!("Mode:"));
widgets::dropdown(ui, "brush-texture-mode", &mut tx.mode, &MASK_MODES, 130.0);
widgets::dropdown(ui, "brush-texture-mode", tl!("Mode"), &mut tx.mode, &MASK_MODES, 130.0);
});
ui.add_space(4.0);
pct(ui, tl!("Depth"), &mut tx.depth, 1.0);
@@ -504,7 +504,7 @@ fn dual_brush(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset])
let d = &mut b.dual_brush;
ui.horizontal(|ui| {
dim(ui, tl!("Mode:"));
widgets::dropdown(ui, "brush-dual-mode", &mut d.mode, &MASK_MODES, 120.0);
widgets::dropdown(ui, "brush-dual-mode", tl!("Mode"), &mut d.mode, &MASK_MODES, 120.0);
ui.add_space(8.0);
widgets::checkbox(ui, &mut d.flip, tl!("Flip"));
});
@@ -651,6 +651,14 @@ mod tests {
kittest::{NodeT, Queryable},
};
/// The slider row's visible caption, not the field or slider it names.
fn caption_rect<T>(h: &Harness<'_, T>, text: &str) -> egui::Rect {
h.query_all(egui_kittest::kittest::by().label(text).include_labels())
.find(|n| n.accesskit_node().role() == Role::Label)
.unwrap_or_else(|| panic!("the `{text}` caption"))
.rect()
}
fn minimum_brush() -> BrushSettings {
let mut b = BrushSettings::default();
b.shape_dynamics.size.minimum = 0.2;
@@ -696,8 +704,8 @@ mod tests {
let y = h.get_by_role_and_label(Role::CheckBox, "Override Tilt Y");
assert_eq!(x.accesskit_node().toggled(), Some(Toggled::True));
assert_eq!(y.accesskit_node().toggled(), Some(Toggled::False));
assert!(h.get_by_label("Tilt X").rect().bottom() < x.rect().top() && x.rect().bottom() < h.get_by_label("Tilt Y").rect().top());
assert!(h.get_by_label("Tilt Y").rect().bottom() < y.rect().top() && y.rect().bottom() < h.get_by_label("Rotation").rect().top());
assert!(caption_rect(&h, "Tilt X").bottom() < x.rect().top() && x.rect().bottom() < caption_rect(&h, "Tilt Y").top());
assert!(caption_rect(&h, "Tilt Y").bottom() < y.rect().top() && y.rect().bottom() < caption_rect(&h, "Rotation").top());
assert_eq!(h.state(), &b, "drawing preserves pose degrees and flags");
y.focus();
h.run_steps(1);
@@ -722,7 +730,7 @@ mod tests {
let mut h = section_harness(5, b.clone());
crate::PhotocraftApp::setup_context(&h.ctx, kind);
h.run_steps(3);
let label = h.get_by_label("Hue Jitter").rect();
let label = caption_rect(&h, "Hue Jitter");
let t = Tokens::get(&h.ctx);
assert!(
h.output().shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Rect(r)
@@ -825,7 +833,7 @@ mod tests {
assert_eq!(h.state().brush, before, "inactive field {value} ignores keyboard and pointer input");
}
for label in ["Wetness Jitter", "Mix Jitter"] {
let row = h.get_by_label(label).rect();
let row = caption_rect(&h, label);
let from = pos2(row.left() + 120.0, row.bottom() + 16.0);
h.hover_at(from);
h.drag_at(from);
@@ -838,7 +846,7 @@ mod tests {
}
h.state_mut().tool = Tool::MixerBrush;
h.run_steps(2);
let row = h.get_by_label("Wetness Jitter").rect();
let row = caption_rect(&h, "Wetness Jitter");
let from = pos2(row.left() + 120.0, row.bottom() + 16.0);
h.hover_at(from);
h.drag_at(from);
@@ -932,7 +940,7 @@ mod tests {
// The slider below the number and its scrubby label are disabled too.
let track = pos2(300.0, center.y + 24.0);
drag(&mut h, track, track);
let label = h.get_by_label("Minimum Diameter").rect().center();
let label = caption_rect(&h, "Minimum Diameter").center();
drag(&mut h, label, label + vec2(60.0, 0.0));
assert_eq!(h.state(), &before, "disabled interactions never rewrite brush settings");
+11 -9
View File
@@ -353,7 +353,7 @@ fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushP
let (full, resp) = ui.allocate_exact_size(vec2(ui.available_width(), list_row_height(scale)), Sense::click_and_drag());
// Rows inside nested folders are indented; the whole width stays the drop target.
let r = egui::Rect::from_min_max(full.min + vec2(indent.min(full.width() / 2.0), 0.0), full.max);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, current, &p.name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, current, &p.name));
if !ui.is_rect_visible(r) {
return;
}
@@ -407,7 +407,7 @@ pub(crate) fn grid_columns(width: f32, cell: f32, indent: f32, gap: f32) -> (f32
fn grid_cell(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], cell: f32, acts: &mut Vec<Action>) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(cell, cell + 6.0), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, current, &p.name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, current, &p.name));
if !ui.is_rect_visible(r) {
return;
}
@@ -555,7 +555,7 @@ fn preset_card(
0.0
};
let (full, resp) = ui.allocate_exact_size(vec2(width, pad_y * 2.0 + top_h + name_h), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, current, &p.name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, current, &p.name));
if !ui.is_rect_visible(full) {
return;
}
@@ -696,7 +696,7 @@ fn group_header(ui: &mut egui::Ui, label: &str, key: &str, folder: &[String], op
let font = if folder.is_empty() { theme::semibold(12.0) } else { theme::medium(12.0) };
ui.painter().text(pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, shown, font, t.text);
ui.painter().text(pos2(r.right() - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, count.to_string(), theme::medium(11.0), t.text_faint);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::CollapsingHeader, true, shown));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::DisclosureTriangle, true, shown));
let me = match folder {
[] => BrushDrag::Group(key.to_string()),
f => BrushDrag::Folder { group: key.to_string(), folder: f.to_vec() },
@@ -866,8 +866,10 @@ fn rename_bar(ui: &mut egui::Ui, st: &mut BrushesPanelState, acts: &mut Vec<Acti
}
let mut done = false;
ui.horizontal(|ui| {
ui.label(RichText::new(if r.group { tl!("Group name") } else { tl!("Brush name") }).color(t.text_dim));
let resp = ui.add(egui::TextEdit::singleline(&mut r.text).desired_width((ui.available_width() - 140.0).max(60.0)).id_salt("brush-rename"));
let name_label = ui.label(RichText::new(if r.group { tl!("Group name") } else { tl!("Brush name") }).color(t.text_dim));
let resp = ui
.add(egui::TextEdit::singleline(&mut r.text).desired_width((ui.available_width() - 140.0).max(60.0)).id_salt("brush-rename"))
.labelled_by(name_label.id);
if !resp.has_focus() && !resp.lost_focus() {
resp.request_focus();
}
@@ -952,7 +954,7 @@ pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<&
}
BrushDrag::Folder { folder, .. } => folder.last().cloned().unwrap_or_default(),
};
egui::Area::new(egui::Id::new(("brush-drag-label", layout.id))).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show(
egui::Area::new(egui::Id::unique(("brush-drag-label", layout.id))).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show(
ui.ctx(),
|ui| {
if matches!(&*d, BrushDrag::Preset(_)) {
@@ -1002,7 +1004,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
r
});
let mut s = b.size;
if widgets::value_field(ui, &mut s, 1.0..=MAX_BRUSH_SIZE, "px", 74.0).changed() {
if widgets::value_field(ui, &mut s, 1.0..=MAX_BRUSH_SIZE, "px", 74.0).accessible_name(tl!("Size")).changed() {
b.size = s.round().clamp(1.0, MAX_BRUSH_SIZE);
}
});
@@ -1012,7 +1014,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
ui.add_space(20.0);
let search_w = (inner_w - 112.0).clamp(90.0, inner_w - 40.0);
widgets::text_field(ui, ui.id().with("brush-search"), &mut app.ui.brushes_panel.filter, tl!("Search Brushes"), search_w, false);
widgets::text_field(ui, ui.scope_id().with("brush-search"), &mut app.ui.brushes_panel.filter, tl!("Search Brushes"), search_w, false);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let view = &mut app.ui.brushes_panel.view;
let grid_tip = "Grid view";
+2 -2
View File
@@ -262,7 +262,7 @@ fn brush_picker_menu_closes_when_a_slider_or_the_grip_is_grabbed() {
h.state_mut().ui.tool = crate::state::Tool::Brush;
h.state_mut().ui.brush_picker = Some([300.0, 120.0]);
h.run_steps(8);
let picker = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-brush-picker"))).expect("the picker is shown");
let picker = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-brush-picker"))).expect("the picker is shown");
assert!(!egui::Popup::is_any_open(&h.ctx), "nothing open to start with");
click_gear(&h);
h.run_steps(2);
@@ -312,7 +312,7 @@ fn brush_picker_upper_controls_stop_at_their_max_width() {
h.state_mut().ui.brush_picker = Some([300.0, 120.0]);
h.state_mut().ui.brush_picker_size = Some([500.0, 420.0]);
h.run_steps(8);
let picker = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-brush-picker"))).expect("the picker is shown");
let picker = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-brush-picker"))).expect("the picker is shown");
let size_label = h.query_all_by_label(crate::i18n::t("Size")).find(|n| picker.contains(n.rect().center())).expect("the picker's Size label").rect();
// The Size slider's middle: 12 pt below the label's (the row stands as tall as its 24 pt value
// field, the label centred in it), the style's spacing, then half the slider's 18 pt — 27 pt
+18 -11
View File
@@ -1243,7 +1243,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let mut reset_what: Option<&str> = None;
let mut rebase_needed = false;
let mut dragging = false;
egui::Area::new(egui::Id::new("caf-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| {
egui::Area::new(egui::Id::unique("caf-workspace")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| {
let Some(w) = app.distort.caf.as_mut() else { return };
let before = w.opts.clone();
w.texture_limit = ctx.input(|i| i.max_texture_side);
@@ -1279,7 +1279,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
bui.add_space(8.0);
bui.label(tl!("Size:"));
let mut size = w.brush_size as f32;
if widgets::value_field(&mut bui, &mut size, 1.0..=5000.0, " px", 64.0).changed() {
if widgets::value_field(&mut bui, &mut size, 1.0..=5000.0, " px", 64.0).accessible_name(tl!("Size:")).changed() {
w.brush_size = f64::from(size);
}
}
@@ -1303,7 +1303,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
if widgets::secondary_button(&mut bui, tl!("Contract"), 70.0).clicked() {
fill_btn = Some(json!({"op": "contract", "by": w.expand_by}));
}
widgets::value_field(&mut bui, &mut w.expand_by, 1.0..=100.0, " px", 56.0);
widgets::value_field(&mut bui, &mut w.expand_by, 1.0..=100.0, " px", 56.0).accessible_name(tl!("Expand"));
if crate::icons::button(&mut bui, "undo-2", 26.0, false, tl!("Reset")).clicked() {
reset_what = Some("lasso");
}
@@ -1338,7 +1338,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
widgets::color_edit_button_srgb(ui, &mut w.opts.color);
ui.add_space(8.0);
ui.label(tl!("Indicates"));
widgets::dropdown(ui, "caf-indicates", &mut w.opts.excluded, &[(false, tl!("Sampling Area")), (true, tl!("Excluded Area"))], 120.0);
widgets::dropdown(
ui,
"caf-indicates",
tl!("Indicates"),
&mut w.opts.excluded,
&[(false, tl!("Sampling Area")), (true, tl!("Excluded Area"))],
120.0,
);
});
});
widgets::hairline(ui);
@@ -1377,7 +1384,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
("high".to_string(), tl!("High")),
("veryHigh".to_string(), tl!("Very High")),
];
widgets::dropdown(ui, "caf-color", &mut w.opts.color_adaptation, &o, 110.0);
widgets::dropdown(ui, "caf-color", tl!("Color Adaptation"), &mut w.opts.color_adaptation, &o, 110.0);
});
ui.horizontal(|ui| {
ui.label(tl!("Rotation Adaptation"));
@@ -1388,7 +1395,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
("high".to_string(), tl!("High")),
("full".to_string(), tl!("Full")),
];
widgets::dropdown(ui, "caf-rotation", &mut w.opts.rotation, &o, 110.0);
widgets::dropdown(ui, "caf-rotation", tl!("Rotation Adaptation"), &mut w.opts.rotation, &o, 110.0);
});
ui.horizontal(|ui| {
widgets::checkbox(ui, &mut w.opts.scale, tl!("Scale"));
@@ -1400,7 +1407,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.label(tl!("Output To"));
let o =
[("current".to_string(), tl!("Current Layer")), ("new".to_string(), tl!("New Layer")), ("duplicate".to_string(), tl!("Duplicate Layer"))];
widgets::dropdown(ui, "caf-output", &mut w.opts.output, &o, 140.0);
widgets::dropdown(ui, "caf-output", tl!("Output To"), &mut w.opts.output, &o, 140.0);
});
if let Some(e) = w.error.as_ref().or(w.preview.error.as_ref()) {
ui.label(egui::RichText::new(e.as_str()).color(t.danger).size(11.0));
@@ -1453,7 +1460,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let sp = ERect::from_min_size(pv_area.right_bottom() - vec2(26.0, 26.0), vec2(18.0, 18.0));
ui.put(sp, egui::Spinner::new().size(16.0));
}
let presp = ui.interact(pv_area, egui::Id::new("caf-preview-view"), Sense::click_and_drag());
let presp = ui.interact(pv_area, egui::Id::unique("caf-preview-view"), Sense::click_and_drag());
if presp.dragged() {
let d = presp.drag_delta();
w.preview.center = [w.preview.center[0] - f64::from(d.x / pv.zoom), w.preview.center[1] - f64::from(d.y / pv.zoom)];
@@ -1467,11 +1474,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let zrow = ERect::from_min_size(pos2(pv_rect.left() + 10.0, pv_rect.bottom() - 38.0), vec2(PREVIEW_W - 20.0, 30.0));
let mut zr = ui.new_child(egui::UiBuilder::new().max_rect(zrow).layout(egui::Layout::left_to_right(egui::Align::Center)));
let mut pct = (w.preview.zoom * 100.0).round();
if widgets::value_field(&mut zr, &mut pct, 1.0..=3200.0, "%", 64.0).changed() {
if widgets::value_field(&mut zr, &mut pct, 1.0..=3200.0, "%", 64.0).accessible_name(tl!("Zoom")).changed() {
w.preview.zoom = pct / 100.0;
}
let mut lz = pct.max(1.0).ln();
if zr.add(egui::Slider::new(&mut lz, 0.0..=3200f32.ln()).show_value(false)).changed() {
if zr.add(egui::Slider::new(&mut lz, 0.0..=3200f32.ln()).show_value(false)).accessible_name(tl!("Zoom")).changed() {
w.preview.zoom = lz.exp() / 100.0;
}
// The document view.
@@ -1481,7 +1488,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
if w.zoom <= 0.0 {
w.zoom = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / chh).clamp(0.01, 4.0);
}
let resp = ui.interact(area, egui::Id::new("caf-view"), Sense::click_and_drag());
let resp = ui.interact(area, egui::Id::unique("caf-view"), Sense::click_and_drag());
if resp.hovered() {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0
+5 -5
View File
@@ -100,7 +100,7 @@ struct ZoomCapture {
/// precedence over the persistent Zoom/Hand tool, so a sampler click never zooms.
pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: Rect, size: Vec2, sampler: &mut bool) -> (Rect, Response, bool, Option<Rect>) {
let sampling = *sampler;
let response = ui.interact(viewport, ui.id().with("cr-sample-preview"), Sense::click_and_drag());
let response = ui.interact(viewport, ui.scope_id().with("cr-sample-preview"), Sense::click_and_drag());
let modifiers = ui.input(|i| i.modifiers);
let temporary_zoom = modifiers.command && (state.hand || sampling);
let hand = !temporary_zoom && (state.hand && !sampling || ui.input(|i| i.key_down(Key::Space)));
@@ -111,7 +111,7 @@ pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport:
// Reuse the canvas wheel policy: retain the modifiers of the wheel event while egui
// smooths it, even if Alt is released before the final part of the notch arrives.
let cr_hovered = response.hovered();
let wheel = crate::wheel_nav::read(ui.ctx(), false, true, ui.id().with("cr-wheel"), cr_hovered);
let wheel = crate::wheel_nav::read(ui.ctx(), false, true, ui.scope_id().with("cr-wheel"), cr_hovered);
if cr_hovered {
ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO);
match wheel {
@@ -236,7 +236,7 @@ pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport:
pub(crate) fn toolbar(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: Rect, size: Vec2, sampler: &mut bool) {
let zoom_tool = crate::icons::button(ui, "zoom-in", 28.0, !state.hand && !*sampler, tl!("Zoom Tool"));
zoom_tool.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Zoom Tool")));
zoom_tool.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Zoom Tool")));
if zoom_tool.double_clicked() {
state.fit();
}
@@ -245,7 +245,7 @@ pub(crate) fn toolbar(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport:
*sampler = false;
}
let hand_tool = crate::icons::button(ui, "hand", 28.0, state.hand && !*sampler, tl!("Hand Tool"));
hand_tool.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Hand Tool")));
hand_tool.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Hand Tool")));
if hand_tool.double_clicked() {
state.fit();
}
@@ -269,7 +269,7 @@ pub(crate) fn toolbar(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport:
if !options.iter().any(|(z, _)| *z == zoom) {
options.push((zoom, &current_label));
}
if widgets::dropdown(ui, "cr-preview-zoom", &mut zoom, &options, 80.0) {
if widgets::dropdown(ui, "cr-preview-zoom", tl!("Zoom"), &mut zoom, &options, 80.0) {
match zoom {
Some(z) => state.zoom_at(z, viewport.center(), viewport, size),
None => state.fit(),
+4 -4
View File
@@ -344,8 +344,8 @@ fn readout_text(value: Option<Value>) -> String {
fn warning(ui: &mut egui::Ui, rect: Rect, mask: u8, on: &mut bool, label: &str) {
let t = Tokens::get(ui.ctx());
let response = ui.interact(rect, ui.id().with(label), Sense::click()).on_hover_text(label);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, label));
let response = ui.interact(rect, ui.scope_id().with(label), Sense::click()).on_hover_text(label);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), *on, label));
let r = rect.shrink(3.0);
ui.painter().add(egui::Shape::convex_polygon(
vec![pos2(r.center().x, r.top()), r.left_bottom(), r.right_bottom()],
@@ -381,7 +381,7 @@ fn graph(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeSt
let t = Tokens::get(ui.ctx());
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width().max(1.0), height), Sense::click_and_drag());
d.scope.rect = Some(rect);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), tl!("Tone Histogram")));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, ui.is_enabled(), tl!("Tone Histogram")));
ui.painter().rect_filled(rect, t.radius_sm, t.histogram_background());
let plot = rect.shrink(4.0);
// Camera Raw's scale: the typical columns set it and a shadow or highlight spike runs off the top.
@@ -525,7 +525,7 @@ pub(crate) fn floating(ctx: &egui::Context, d: &mut CameraRawDialog, view: &mut
r[1].clamp(content.top(), (content.bottom() - 150.0).max(content.top())),
);
let window = egui::Window::new("")
.id(egui::Id::new("cr-floating-scope"))
.id(egui::Id::unique("cr-floating-scope"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(true)
+5 -4
View File
@@ -1119,7 +1119,7 @@ fn demosaic_row(ui: &mut egui::Ui, m: &mut photocraft_io::raw::Demosaic) {
(Demosaic::Mhc, "Malvar–He–Cutler", "Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail."),
(Demosaic::Bilinear, "Bilinear", "The fastest and the softest, with colour fringing on fine detail."),
];
widgets::dropdown_with_tooltips(ui, "camera-raw-demosaic", m, &options, 180.0);
widgets::dropdown_with_tooltips(ui, "camera-raw-demosaic", tl!("Demosaic"), m, &options, 180.0);
});
ui.add_space(4.0);
}
@@ -1226,7 +1226,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Camera Raw is modal, as in Photoshop: the main window is dimmed and takes no input while it
// is open (in a separate window, or drawn over it where there are no extra windows).
let main = ctx.content_rect();
egui::Area::new(egui::Id::new("camera-raw-modal")).order(egui::Order::Foreground).fixed_pos(main.min).show(ctx, |ui| {
egui::Area::new(egui::Id::unique("camera-raw-modal")).order(egui::Order::Foreground).fixed_pos(main.min).show(ctx, |ui| {
let (r, _) = ui.allocate_exact_size(main.size(), Sense::click_and_drag());
ui.painter().rect_filled(r, 0.0, Tokens::get(ui.ctx()).scrim);
});
@@ -1262,7 +1262,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) {
let screen = ctx.content_rect();
let current_display = app.session.active().and_then(|st| app.session.color.canvas_display(&st.doc).ok());
let mut action: Option<&str> = None;
egui::Area::new(egui::Id::new("camera-raw-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| {
egui::Area::new(egui::Id::unique("camera-raw-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| {
let Some(d) = app.camera_raw.as_mut() else { return };
history_keys(d, ctx);
let pointer = ctx.input(|i| i.pointer.primary_down());
@@ -1377,6 +1377,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) {
if widgets::dropdown(
ui,
"cr-vig-style",
tl!("Vignetting"),
&mut style,
&[
("highlightPriority".to_string(), tl!("Highlight Priority")),
@@ -1410,7 +1411,7 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) {
for (i, source) in ["Hue", "Saturation", "Luminance"].iter().enumerate() {
let name = crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", source);
let response = widgets::pill_tab(ui, name, d.mixer_tab == i);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), d.mixer_tab == i, name));
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), d.mixer_tab == i, name));
if response.clicked() {
d.mixer_tab = i;
}
+58 -50
View File
@@ -610,22 +610,27 @@ pub(crate) fn draw_readout<const N: usize>(ctx: &egui::Context, id: &str, cursor
let labels = labels.map(|label| tl!(label));
let (lw, vw) = (labels.map(width), values.each_ref().map(|v| width(v)));
let (label_col, value_col) = (lw.iter().fold(0.0f32, |a, &b| a.max(b)), vw.iter().fold(0.0f32, |a, &b| a.max(b)));
egui::Area::new(egui::Id::new(id)).order(egui::Order::Tooltip).fixed_pos(cursor + vec2(16.0, 18.0)).interactable(false).constrain(true).show(ctx, |ui| {
egui::Frame::new().fill(t.card).stroke(Stroke::new(1.0, t.card_border)).corner_radius(t.radius_sm).inner_margin(egui::Margin::symmetric(7, 4)).show(
ui,
|ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 1.0);
for (i, value) in values.into_iter().enumerate() {
// Labels left, values right-aligned on one edge, sharing a baseline.
ui.horizontal(|ui| {
ui.add(egui::Label::new(egui::RichText::new(labels[i]).font(font.clone()).color(t.text_dim)).extend());
ui.add_space(label_col - lw[i] + 12.0 + value_col - vw[i]);
ui.add(egui::Label::new(egui::RichText::new(value).font(font.clone()).color(t.text)).extend());
});
}
},
);
});
egui::Area::new(egui::Id::unique(id)).order(egui::Order::Tooltip).fixed_pos(cursor + vec2(16.0, 18.0)).interactable(false).constrain(true).show(
ctx,
|ui| {
egui::Frame::new()
.fill(t.card)
.stroke(Stroke::new(1.0, t.card_border))
.corner_radius(t.radius_sm)
.inner_margin(egui::Margin::symmetric(7, 4))
.show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 1.0);
for (i, value) in values.into_iter().enumerate() {
// Labels left, values right-aligned on one edge, sharing a baseline.
ui.horizontal(|ui| {
ui.add(egui::Label::new(egui::RichText::new(labels[i]).font(font.clone()).color(t.text_dim)).extend());
ui.add_space(label_col - lw[i] + 12.0 + value_col - vw[i]);
ui.add(egui::Label::new(egui::RichText::new(value).font(font.clone()).color(t.text)).extend());
});
}
});
},
);
}
/// The engine's live stroke of a tool: the Brush, Pencil and Eraser paint dabs; the Clone Stamp,
@@ -1638,7 +1643,7 @@ pub fn navigator_gpu_view(app: &PhotocraftApp, idx: usize, rect: Rect, ctx: &egu
rotation: 0.0,
shadow: false,
pixel_grid: false,
view_key: egui::Id::new(("pc-navigator", ctx.viewport_id(), idx)).value(),
view_key: egui::Id::unique(("pc-navigator", ctx.viewport_id(), idx)).value(),
offset: [0.0, 0.0],
..params
})
@@ -1659,7 +1664,7 @@ pub fn navigator_waits_for_gpu(app: &PhotocraftApp, idx: usize, ctx: &egui::Cont
if app.canvases.keys().any(|(d, _)| *d == id) {
return false;
}
let key = egui::Id::new(("navigator-waits", id.0));
let key = egui::Id::unique(("navigator-waits", id.0));
let waited = ctx.data(|d| d.get_temp::<u32>(key)).unwrap_or(0);
if waited >= NAVIGATOR_WAIT_FRAMES {
return false;
@@ -2797,11 +2802,11 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
let name_g = crate::tab_strip::elided(ui, &name, font.clone(), t.text, name_max);
// The title was cut: the tooltip has the rest of it.
let cut = name_g.size().x + 0.5 < natural_w - STUDIO_TAB_PAD + STUDIO_TAB_GAP - meta_g.size().x;
let resp = ui.interact(r, ui.id().with(("dtab", i)), Sense::click_and_drag());
let resp = ui.interact(r, ui.scope_id().with(("dtab", i)), Sense::click_and_drag());
let saving = crate::jobs_ui::save_progress(app, st.doc.id);
resp.widget_info(|| {
let label = saving.as_ref().map_or_else(|| st.doc.name.clone(), |s| format!("{} - {s}", st.doc.name));
egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, label)
egui::WidgetInfo::selected(egui::Role::Button, true, sel, label)
});
doc_tabs.push((i, r));
if sel {
@@ -2819,7 +2824,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
ui.painter().galley_with_override_text_color(egui::pos2(r.left() + 10.0, ny), name_g.clone(), color);
ui.painter().galley(egui::pos2(r.left() + 16.0 + name_g.size().x, r.center().y - meta_g.size().y / 2.0), meta_g, t.text_faint);
let xr = Rect::from_center_size(egui::pos2(r.right() - 12.0, r.center().y), egui::vec2(16.0, 16.0));
let xresp = ui.interact(xr, ui.id().with(("dtabx", i)), Sense::click());
let xresp = ui.interact(xr, ui.scope_id().with(("dtabx", i)), Sense::click());
if xresp.hovered() {
ui.painter().rect_filled(xr, 4.0, t.hover);
}
@@ -2846,7 +2851,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
let sel = app.jobs.focus == Some(*job);
let meta_g = ui.painter().layout_no_wrap(format!("{:.0}%", frac * 100.0), meta_font.clone(), t.text_faint);
let name_g = crate::tab_strip::elided(ui, name, font.clone(), t.text, (r.width() - STUDIO_TAB_PAD + STUDIO_TAB_GAP - meta_g.size().x).max(1.0));
let resp = ui.interact(r, ui.id().with(("dtabjob", job.0)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("dtabjob", job.0)), Sense::click());
if sel {
ui.painter().rect_filled(r, t.radius_sm, t.card);
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside);
@@ -2858,7 +2863,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
ui.painter().galley(egui::pos2(r.left() + 16.0 + name_g.size().x, r.center().y - meta_g.size().y / 2.0), meta_g, t.text_faint);
crate::jobs_ui::tab_underline(ui, r, *frac, &t);
let xr = Rect::from_center_size(egui::pos2(r.right() - 12.0, r.center().y), egui::vec2(16.0, 16.0));
let xresp = ui.interact(xr, ui.id().with(("dtabjobx", job.0)), Sense::click());
let xresp = ui.interact(xr, ui.scope_id().with(("dtabjobx", job.0)), Sense::click());
if xresp.hovered() {
ui.painter().rect_filled(xr, 4.0, t.hover);
}
@@ -2879,7 +2884,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
// Like Photoshop's, the menu lists every open document, not only the hidden ones, and checks
// the active one (#4487).
let every: Vec<usize> = (0..labels.len()).collect();
crate::tab_strip::overflow_button(ui, ui.id().with("dtab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked);
crate::tab_strip::overflow_button(ui, ui.scope_id().with("dtab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked);
match picked {
Some(i) if i < tab_count => activate = Some(i),
Some(i) => focus_open = opening.get(i - tab_count).map(|(job, _, _)| *job).or(focus_open),
@@ -2971,7 +2976,7 @@ fn tab_drag(ui: &egui::Ui, resp: &egui::Response, i: usize, r: Rect, strip: Rect
{
if torn_off(strip, p) {
let ghost = Rect::from_min_size(p - egui::vec2(40.0, 12.0), egui::vec2(240.0, 160.0));
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, ui.id().with("tab-tear-off")));
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, ui.scope_id().with("tab-tear-off")));
painter.rect_filled(ghost, t.radius_sm, t.accent.gamma_multiply(0.12));
painter.rect_stroke(ghost, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
} else {
@@ -3142,7 +3147,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
for &(i, r) in placed.iter().filter(|(i, _)| *i < tab_count) {
let Some(title) = titles.get(i) else { continue };
let g = crate::tab_strip::elided(ui, title, font.clone(), t.text, (r.width() - DOC_TAB_PAD).max(1.0));
let resp = ui.interact(r, ui.id().with(("ptab", i)), Sense::click_and_drag());
let resp = ui.interact(r, ui.scope_id().with(("ptab", i)), Sense::click_and_drag());
let sel = Some(i) == active;
if sel {
ui.painter().rect_filled(r, 0.0, t.chrome);
@@ -3155,10 +3160,10 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
}
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.separator));
let (xr, at) = pro_tab_layout(r, g.size().y, mac);
let xresp = ui.interact(xr, ui.id().with(("ptabx", i)), Sense::click());
let xresp = ui.interact(xr, ui.scope_id().with(("ptabx", i)), Sense::click());
// Painted: name the tab and its × for accessibility (and so tests and agents can find them).
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, g.text()));
xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Close")));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, g.text()));
xresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Close")));
crate::icons::paint(ui, xr, "x", 10.0, if xresp.hovered() { t.text } else { t.text_faint });
ui.painter().galley_with_override_text_color(at, g, if sel { t.text } else { t.text_faint });
let cut = natural.get(i).copied().unwrap_or(0.0) > r.width() + 0.5;
@@ -3183,7 +3188,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
let Some((job, _, frac)) = opening.get(i - tab_count) else { continue };
let Some(title) = titles.get(i) else { continue };
let g = crate::tab_strip::elided(ui, title, font.clone(), t.text, (r.width() - DOC_TAB_PAD).max(1.0));
let resp = ui.interact(r, ui.id().with(("ptabjob", job.0)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("ptabjob", job.0)), Sense::click());
let sel = app.jobs.focus == Some(*job);
if sel {
ui.painter().rect_filled(r, 0.0, t.chrome);
@@ -3193,8 +3198,8 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.separator));
crate::jobs_ui::tab_underline(ui, r, *frac, &t);
let (xr, at) = pro_tab_layout(r, g.size().y, mac);
let xresp = ui.interact(xr, ui.id().with(("ptabjobx", job.0)), Sense::click());
xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Close")));
let xresp = ui.interact(xr, ui.scope_id().with(("ptabjobx", job.0)), Sense::click());
xresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Close")));
crate::icons::paint(ui, xr, "x", 10.0, if xresp.hovered() { t.text } else { t.text_faint });
ui.painter().galley_with_override_text_color(at, g, if sel { t.text } else { t.text_faint });
if xresp.on_hover_text(tl!("Cancel opening")).clicked() {
@@ -3212,7 +3217,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
// Like Photoshop's, the menu lists every open document, not only the hidden ones, and checks
// the active one (#4487).
let every: Vec<usize> = (0..labels.len()).collect();
crate::tab_strip::overflow_button(ui, ui.id().with("ptab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked);
crate::tab_strip::overflow_button(ui, ui.scope_id().with("ptab-overflow"), r, tl!("More documents"), &labels, &every, Some(selected), &mut picked);
match picked {
Some(i) if i < tab_count => activate = Some(i),
Some(i) => focus_open = opening.get(i - tab_count).map(|(job, _, _)| *job).or(focus_open),
@@ -3295,7 +3300,7 @@ fn dots(ctx: &egui::Context, t: &crate::theme::Tokens) -> Option<egui::TextureId
if t.bevel || t.canvas_dot == t.canvas {
return None;
}
let key = egui::Id::new(("canvas-dots", format!("{:?}", t.kind)));
let key = egui::Id::unique(("canvas-dots", format!("{:?}", t.kind)));
if let Some(tex) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
return Some(tex.id());
}
@@ -3345,7 +3350,7 @@ pub fn mode_label(doc: &Document) -> &'static str {
/// (#5367), and so does the Home screen's blank background. Registered before anything drawn over
/// `area`, so the buttons and links there keep their own clicks; not a keyboard focus stop.
fn open_on_double_click(app: &mut PhotocraftApp, ui: &egui::Ui, area: Rect) {
if app.session.documents().is_empty() && ui.interact(area, ui.id().with("empty-workspace"), Sense::CLICK).double_clicked() {
if app.session.documents().is_empty() && ui.interact(area, ui.scope_id().with("empty-workspace"), Sense::CLICK).double_clicked() {
let _ = app.open_dialog_file();
}
}
@@ -3484,12 +3489,12 @@ fn home_recent(app: &mut PhotocraftApp, ui: &mut egui::Ui, recent: &[String]) {
let fg = crate::tab_strip::elided(ui, &folder, egui::FontId::proportional(11.5), t.text_faint, folder_max);
ui.painter().galley(egui::pos2(right - fg.size().x, row.center().y - fg.size().y / 2.0), fg, t.text_faint);
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
let resp = resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand);
if hovered {
let x = ui.interact(close, resp.id.with("remove"), Sense::click());
let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", &name)]);
x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label));
x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
let color = crate::icons::button_chrome(ui, close, false, x.hovered());
crate::icons::paint(ui, close, "x", 12.0, color);
if x.on_hover_text(tl!("Remove from Recent")).clicked() {
@@ -3601,7 +3606,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// too, as in Photoshop (#6017).
let rect = crate::rulers::content_rect(app, rect);
// Both wheel reads share one owner so snap-zoom input is accumulated only once per frame.
let wheel_owner = ui.id().with(("canvas-wheel", idx));
let wheel_owner = ui.scope_id().with(("canvas-wheel", idx));
// Direct navigation starts from the authoritative camera. This also means a click or drag
// never maps through a camera that is still moving under the pointer.
let (pointer_here, hover_here) = ctx.input(|i| {
@@ -3728,7 +3733,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
!t.bevel && !t.pro && drop_shadow
},
pixel_grid: false,
view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(),
view_key: egui::Id::unique(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(),
display: sync_display_lut(app, &shown, key, output),
output: output.unwrap_or(0),
hdr: hdr_preview(app, &doc),
@@ -3750,7 +3755,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
!t.bevel && !t.pro && drop_shadow
},
pixel_grid,
view_key: egui::Id::new(("pc-canvas", ctx.viewport_id(), idx)).value(),
view_key: egui::Id::unique(("pc-canvas", ctx.viewport_id(), idx)).value(),
display: sync_display_lut(app, &doc, doc.id.0, output),
output: output.unwrap_or(0),
hdr: hdr_preview(app, &doc),
@@ -3766,8 +3771,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
app.navigator_gpu.remove(&doc.id);
// A document too large for one full-resolution texture also gets the part in view at
// the screen's resolution, drawn over the reduced whole (#7568).
let in_view =
InView { visible: visible_doc_rect(&xf), zoom: view.zoom, key: egui::Id::new(("pc-canvas-detail", ctx.viewport_id(), idx, primary)).value() };
let in_view = InView {
visible: visible_doc_rect(&xf),
zoom: view.zoom,
key: egui::Id::unique(("pc-canvas-detail", ctx.viewport_id(), idx, primary)).value(),
};
let textures = ensure_texture_in_view(app, &ctx, idx, output, Some(in_view));
let tex = textures.as_ref().map(|t| t.texture);
// The area the image covers: a size-changing preview's own (Image › Rotation).
@@ -3980,7 +3988,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
// Navigation (wheel_nav.rs): scroll pans (⇧ or ⌘/Ctrl sideways); pinch and ⌥-scroll zoom around the pointer.
// The wheel also scrolls over the scrollbars drawn on top of the canvas (last frame's hover).
let bars_id = egui::Id::new(("pc-canvas-bars-hover", idx));
let bars_id = egui::Id::unique(("pc-canvas-bars-hover", idx));
let over_bars = ctx.data(|d| d.get_temp::<bool>(bars_id)).unwrap_or(false);
let nav_hovered = response.hovered() || free_hover || over_bars;
// Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721).
@@ -4203,7 +4211,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let transform_owns_pointer = primary && app.ui.transform.is_some() && temporary.is_none() && !middle;
let hand_pan = tool == Tool::Hand && !transform_owns_pointer;
// Preferences › Tools › Enable Flick Panning: a Hand drag let go while moving glides on.
let flick_id = egui::Id::new(("pc-flick-pan", idx, primary, ctx.viewport_id()));
let flick_id = egui::Id::unique(("pc-flick-pan", idx, primary, ctx.viewport_id()));
let flick_on = app.session.prefs().tools.enable_flick_panning;
if let Some(d) = crate::flick_pan::frame(&ctx, flick_id, hand_pan && response.dragged() && !stroke_dismissal, response.drag_delta(), flick_on) {
let d = xf.unmap_vec(d) / (view.zoom / ppp);
@@ -4246,7 +4254,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
} else if primary && !stroke_dismissal {
let mods = ui.input(|i| i.modifiers);
// An ⌥-click the window manager took never arrives; say so (alt_grab.rs).
crate::alt_grab::watch(app, &ctx, egui::Id::new(("pc-alt-grab", idx)), rect, tool, !under_dialog);
crate::alt_grab::watch(app, &ctx, egui::Id::unique(("pc-alt-grab", idx)), rect, tool, !under_dialog);
// Tools follow the left button; the right one opens the Brush Preset picker or erases
// (Preferences › Tools, `paint_mouse`).
crate::paint_mouse::sync_tool_brush(app);
@@ -4260,7 +4268,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// Type transforms keep Command; selections keep Add/Subtract if Shift/Alt is released.
let capture_press = tool.is_type()
|| matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee | Tool::ObjectSelection);
let tool_press = egui::Id::new(("tool-pointer-press-modifiers", idx));
let tool_press = egui::Id::unique(("tool-pointer-press-modifiers", idx));
if capture_press
&& let Some((p, press_mods)) = ui.input(|i| {
i.events.iter().find_map(|e| match e {
@@ -4971,7 +4979,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let t0 = crate::gpu_canvas::now_ms();
let before = view.center;
let over_bars = app.ui.view.shows_scrollbars()
&& crate::scrollbars::show(ui, rect, &mut view, flip, aspect, app.session.prefs().tools.overscroll, egui::Id::new(("pc-canvas", idx)));
&& crate::scrollbars::show(ui, rect, &mut view, flip, aspect, app.session.prefs().tools.overscroll, egui::Id::unique(("pc-canvas", idx)));
ctx.data_mut(|d| d.insert_temp(bars_id, over_bars));
if view.center != before {
ctx.request_repaint();
@@ -5066,7 +5074,7 @@ fn paint_gpu_view(painter: &egui::Painter, rect: Rect, params: crate::gpu_canvas
offset: crate::pattern_preview::offset(c, params.doc_size),
shadow: false,
// Keyed by slot, not by tile index, so panning reuses the same few uniform buffers.
view_key: egui::Id::new((params.view_key, "pattern", k)).value(),
view_key: egui::Id::unique((params.view_key, "pattern", k)).value(),
..params
};
crate::gpu_canvas::GpuCanvas::paint(painter, rect, copy);
@@ -5370,7 +5378,7 @@ fn ring(outer: DRect, inner: DRect) -> Vec<DRect> {
fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, output: Option<u32>) -> bool {
let ctx = painter.ctx().clone();
let Some((snapshot, id)) = app.session.documents().get(idx).map(|st| (std::sync::Arc::downgrade(&st.doc), st.doc.id)) else { return false };
let key = egui::Id::new(("crop-beyond-canvas", id.0, output));
let key = egui::Id::unique(("crop-beyond-canvas", id.0, output));
let frame =
app.ui.crop_rect.filter(|f| f.iter().all(|v| v.is_finite()) && app.session.active_index() == Some(idx) && crate::crop_ui::shows_beyond_canvas(app));
let Some(f) = frame else {
@@ -8164,7 +8172,7 @@ mod tests {
/// One frame of the overlay at (32, 32): its shapes and texture uploads.
fn clone_overlay_frame(ctx: &egui::Context, app: &mut PhotocraftApp, xf: &ViewXform) -> egui::FullOutput {
ctx.run_ui(Default::default(), |ui| {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("clone-test")));
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
draw_clone_preview(app, &painter, xf, [32.0, 32.0], None);
})
}
+2 -2
View File
@@ -185,7 +185,7 @@ fn reopened_canvas_menus_still_choose_their_actions() {
dismiss_outside(&mut h, Popup::Layers);
tap(&mut h, at, Modifiers::NONE, true);
assert!(is_open(&h, Popup::Layers));
let menu_rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-layer-menu"))).unwrap();
let menu_rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-layer-menu"))).unwrap();
h.query_all_by_label("Background").find(|node| menu_rect.contains(node.rect().center())).expect("Background in the layer menu").click();
h.run_steps(3);
assert!(!is_open(&h, Popup::Layers));
@@ -362,7 +362,7 @@ fn brush_preset_menu_action_outside_the_picker_keeps_it_open() {
let before = document_state(&h);
let at = fractional_point(&h, 1.0);
tap(&mut h, at, Modifiers::NONE, true);
let picker = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-brush-picker"))).unwrap();
let picker = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-brush-picker"))).unwrap();
let name = h.state().session.tools.presets.first().unwrap().name.clone();
let card = h.query_all_by_label(&name).find(|n| picker.contains(n.rect().center())).expect("visible preset card").rect();
// Open by the right edge so the action lands outside the picker on a foreground menu.
+3 -3
View File
@@ -33,7 +33,7 @@ mod tests {
#[test]
fn opening_frame_survives_layout_retries_and_later_presses_close() {
let ctx = Context::default();
let id = Id::new("menu");
let id = Id::unique("menu");
let rect = Rect::from_min_size(pos2(6.0, 6.0), vec2(100.0, 100.0));
for _ in 0..3 {
let mut passes = 0;
@@ -66,8 +66,8 @@ mod tests {
#[test]
fn opening_one_menu_does_not_protect_another() {
let ctx = Context::default();
let first = Id::new("first");
let second = Id::new("second");
let first = Id::unique("first");
let second = Id::unique("second");
let rect = Rect::from_min_size(pos2(6.0, 6.0), vec2(100.0, 100.0));
ctx.run_ui(RawInput::default(), |ui| mark_open(ui.ctx(), first)).drop_without_applying_deltas();
ctx.run_ui(outside_press(), |ui| {
+1 -1
View File
@@ -937,7 +937,7 @@ fn choose_pen(app: &mut PhotocraftApp, ctx: &Context, menu: &CanvasToolMenu, com
}
fn menu_id() -> egui::Id {
egui::Id::new("canvas-selection-menu")
egui::Id::unique("canvas-selection-menu")
}
pub fn show(app: &mut PhotocraftApp, ctx: &Context) {
+3 -2
View File
@@ -206,7 +206,8 @@ pub fn fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSession)
let Some(o) = t.content_aware.clone() else { return };
let tk = crate::theme::Tokens::get(ui.ctx());
let lbl = |ui: &mut egui::Ui, s: &str| {
ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0));
let label = ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0));
crate::widgets::scrub_option_label(ui, &label);
};
crate::transform_tool::position_size_fields(app, ui, t);
crate::widgets::vline(ui, 22.0);
@@ -218,7 +219,7 @@ pub fn fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSession)
let channels: Vec<String> = app.session.active().map(|st| st.doc.channels.iter().map(|c| c.name.clone()).collect()).unwrap_or_default();
let mut options: Vec<(Option<String>, &str)> = vec![(None, tl!("None"))];
options.extend(channels.iter().map(|n| (Some(n.clone()), n.as_str())));
crate::widgets::dropdown(ui, "content-aware-scale-protect", &mut next.protect, &options, 120.0);
crate::widgets::dropdown(ui, "content-aware-scale-protect", tl!("Protect:"), &mut next.protect, &options, 120.0);
crate::widgets::checkbox(ui, &mut next.protect_skin_tones, tl!("Protect skin tones"));
if next != o
&& let Some(s) = app.ui.transform.as_mut()
+7 -6
View File
@@ -110,7 +110,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
if let Some(mut name) = f.get("name").and_then(Value::as_str).map(str::to_string) {
ui.horizontal(|ui| {
label(ui, tl!("Name:"));
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(200.0)).changed() {
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(200.0)).accessible_name(tl!("Name")).changed() {
f.insert("name".into(), json!(name));
}
});
@@ -143,9 +143,10 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
crate::color_picker_ui::request(f, &crate::color_picker_ui::title_for(what));
}
ui.add_space(12.0);
ui.label(if spot { tl!("Solidity:") } else { tl!("Opacity:") });
let caption = if spot { tl!("Solidity:") } else { tl!("Opacity:") };
ui.label(caption);
let mut pct = f.get("opacity").and_then(Value::as_f64).unwrap_or(50.0) as f32;
if crate::widgets::value_field(ui, &mut pct, 0.0..=100.0, "%", 64.0).changed() {
if crate::widgets::value_field(ui, &mut pct, 0.0..=100.0, "%", 64.0).accessible_name(caption).changed() {
f.insert("opacity".into(), json!(pct.round()));
}
});
@@ -163,7 +164,7 @@ fn mask_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.add_space(12.0);
ui.label(tl!("Opacity:"));
let mut pct = f.get("opacity").and_then(Value::as_f64).unwrap_or(50.0) as f32;
if crate::widgets::value_field(ui, &mut pct, 0.0..=100.0, "%", 64.0).changed() {
if crate::widgets::value_field(ui, &mut pct, 0.0..=100.0, "%", 64.0).accessible_name(tl!("Opacity:")).changed() {
f.insert("opacity".into(), json!(pct.round()));
}
});
@@ -228,7 +229,7 @@ pub fn new_spot_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let mut name = f.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
ui.horizontal(|ui| {
label(ui, tl!("Name:"));
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(200.0)).changed() {
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(200.0)).accessible_name(tl!("Name")).changed() {
f.insert("name".into(), json!(name));
}
});
@@ -244,7 +245,7 @@ pub fn new_spot_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.add_space(12.0);
ui.label(tl!("Solidity:"));
let mut pct = f.get("solidity").and_then(Value::as_f64).unwrap_or(0.0) as f32;
if crate::widgets::value_field(ui, &mut pct, 0.0..=100.0, "%", 64.0).changed() {
if crate::widgets::value_field(ui, &mut pct, 0.0..=100.0, "%", 64.0).accessible_name(tl!("Solidity:")).changed() {
f.insert("solidity".into(), json!(pct.round()));
}
});
+9 -9
View File
@@ -165,13 +165,13 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator));
}
let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 11.0), vec2(22.0, 22.0));
let eye_resp = ui.interact(eye, ui.id().with(("chan-eye", format!("{:?}", row.reference()))), Sense::click());
let eye_resp = ui.interact(eye, ui.scope_id().with(("chan-eye", format!("{:?}", row.reference()))), Sense::click());
// Like Photoshop, the temporary mask row's eye is empty until the mask is shown.
if row != Row::LayerMask || visible || eye_resp.hovered() {
icons::paint(ui, eye, if visible { "eye" } else { "eye-off" }, 14.0, if visible { t.icon } else { t.text_faint });
}
let eye_resp = eye_resp.on_hover_text(if row == Row::LayerMask { tl!("Show the layer mask as an overlay") } else { tl!("Toggle visibility") });
eye_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Visibility {name}")));
eye_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Visibility {name}")));
let layer_id = masked.as_ref().map(|l| l.id.0);
if eye_resp.clicked() {
let view_cmd = photocraft_engine::mask_view_cmds::ID.to_string();
@@ -193,7 +193,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
drawn.push((name.clone(), thumb));
// Rows are painted: name them for screen readers and UI tests.
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &name));
// Spot channels: a swatch of the ink on the thumbnail corner.
if let Row::Alpha(i) = row
&& let Some((ink, _)) = doc.channels[i].spot
@@ -223,7 +223,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
t.text_faint,
);
}
let rename_id = egui::Id::new(("chan-rename", doc.id.0, format!("{:?}", row.reference())));
let rename_id = egui::Id::unique(("chan-rename", doc.id.0, format!("{:?}", row.reference())));
if resp.clicked() && !eye_resp.clicked() {
let m = ui.input(|i| i.modifiers);
if m.command && row == Row::LayerMask {
@@ -261,7 +261,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
if let Some(mut text) = ctx.data(|d| d.get_temp::<String>(rename_id)) {
let edit_rect = Rect::from_min_max(pos2(text_pos.x - 3.0, rect.center().y - 11.0), pos2(rect.right() - 36.0, rect.center().y + 11.0));
let te = ui.put(edit_rect, egui::TextEdit::singleline(&mut text).font(egui::FontId::proportional(12.5)));
let te = ui.put(edit_rect, egui::TextEdit::singleline(&mut text).font(egui::FontId::proportional(12.5))).accessible_name(tl!("Name"));
if !te.has_focus() && !te.lost_focus() {
te.request_focus();
}
@@ -390,7 +390,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if id == "ui.renameChannel" {
if let Some(i) = p.as_u64().and_then(|i| doc.channels.get(i as usize)) {
let row = Row::Alpha(p.as_u64().unwrap_or(0) as usize);
ctx.data_mut(|d| d.insert_temp(egui::Id::new(("chan-rename", doc.id.0, format!("{:?}", row.reference()))), i.name.clone()));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("chan-rename", doc.id.0, format!("{:?}", row.reference()))), i.name.clone()));
}
continue;
}
@@ -436,11 +436,11 @@ fn drop_action(row: Row, name: &str, mask_layer: Option<u64>, on: Footer) -> Opt
}
fn drag_id() -> egui::Id {
egui::Id::new("channel-row-drag")
egui::Id::unique("channel-row-drag")
}
fn thumbs_id() -> egui::Id {
egui::Id::new("channel-thumb-rects")
egui::Id::unique("channel-thumb-rects")
}
/// The Channels rows drawn last frame: (name, thumbnail rect).
@@ -523,7 +523,7 @@ mod tests {
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
let doc = app.session.active().unwrap().doc.id.0;
let name = app.session.active().unwrap().doc.channels[0].name.clone();
ctx.data_mut(|d| d.insert_temp(egui::Id::new(("chan-rename", doc, format!("{:?}", Row::Alpha(0).reference()))), name));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("chan-rename", doc, format!("{:?}", Row::Alpha(0).reference()))), name));
// The field takes focus on the first pass and owns the IME from the second on (#585).
let interrupts: Vec<Option<bool>> = (0..4)
.map(|_| {
+4 -1
View File
@@ -156,7 +156,10 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
);
let mut pct = app.ui.views[i].zoom * 100.0;
let control_h = crate::panels::status_bar_heights(&t).1;
if widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(64.0, control_h)).changed() {
if widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(64.0, control_h))
.accessible_name(tl!("Zoom"))
.changed()
{
app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size);
app.ui.views[i].zoom_animating = false;
app.ui.views[i].zoom_anchor = None;
+23 -5
View File
@@ -50,6 +50,9 @@ pub struct Sources {
pub covers: fn(&[u8], char) -> Option<u32>,
/// Embedded fonts tried before `files` for a script (craft-fonts' Japanese fonts).
pub embedded: fn(CjkScript) -> Vec<&'static CraftFont>,
/// Register every craft-fonts face up front, so a headless frame can shape CJK and other
/// scripts before the lazy loader gets a second pass (tests panic on a missing glyph).
pub eager_craft_fonts: bool,
}
/// craft-fonts' Japanese fonts for the Japanese script, UI face first (empty without craft-fonts).
@@ -76,6 +79,7 @@ impl Sources {
scripts: scripts::system_candidates,
covers: scripts::covering_face,
embedded: craft_embedded,
eager_craft_fonts: true,
}
}
#[cfg(target_arch = "wasm32")]
@@ -87,6 +91,8 @@ impl Sources {
scripts: |_| Vec::new(),
covers: scripts::covering_face,
embedded: craft_embedded,
// The web build embeds no craft fonts; keep its font stack as it was.
eager_craft_fonts: false,
}
}
}
@@ -351,11 +357,11 @@ fn baseline_offset(face: (f32, f32, f32), primary: (f32, f32, f32)) -> f32 {
/// Registers `name` at the lowest priority in every font family.
fn add_to_all_families(ctx: &egui::Context, name: String, mut data: FontData) {
// Align to Inter, the primary of every UI family; JetBrains Mono differs by under 0.005em.
let primary = ctx.fonts(|f| f.definitions().font_data.get("Inter").and_then(|p| vertical_metrics(&p.font, p.index)));
if let (Some(face), Some(primary)) = (vertical_metrics(&data.font, data.index), primary) {
let primary = ctx.fonts(|f| f.definitions().font_data.get("Inter").and_then(|p| vertical_metrics(p.bytes(), p.index)));
if let (Some(face), Some(primary)) = (vertical_metrics(data.bytes(), data.index), primary) {
data.tweak.y_offset_factor = baseline_offset(face, primary);
}
if let Some(weight) = regular_weight(&data.font, data.index) {
if let Some(weight) = regular_weight(data.bytes(), data.index) {
data.tweak.coords = egui::epaint::text::VariationCoords::new([(b"wght", weight)]);
}
let mut families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
@@ -571,6 +577,7 @@ mod tests {
scripts: |_| Vec::new(),
covers: scripts::covering_face,
embedded: no_embedded,
eager_craft_fonts: false,
}
}
@@ -607,6 +614,7 @@ mod tests {
scripts: |_| Vec::new(),
covers: scripts::covering_face,
embedded: no_embedded,
eager_craft_fonts: false,
});
fb.order = Some(cjk::script_order(Some(loc)));
assert!(fb.next_font(CjkChar::Han).is_none());
@@ -728,6 +736,7 @@ mod tests {
},
covers: scripts::covering_face,
embedded: no_embedded,
eager_craft_fonts: false,
}
}
@@ -859,7 +868,15 @@ mod tests {
/// No system fonts at all: craft-fonts alone draws Japanese.
fn craft_only(locale: fn() -> Option<String>) -> Sources {
Sources { locale, files: |_| vec![], last_resort: Vec::new, scripts: |_| Vec::new(), covers: scripts::covering_face, embedded: craft_embedded }
Sources {
locale,
files: |_| vec![],
last_resort: Vec::new,
scripts: |_| Vec::new(),
covers: scripts::covering_face,
embedded: craft_embedded,
eager_craft_fonts: false,
}
}
#[test]
@@ -876,7 +893,7 @@ mod tests {
assert!(render(&ctx, "レイヤー 1"), "tofu in kana");
let fonts = ctx.fonts(|f| f.definitions().clone());
let name = format!("{FONT_PREFIX}-0");
assert!(fonts.font_data.get(&name).is_some_and(|d| std::ptr::eq(d.font.as_ref().as_ptr(), ui_font.bytes.as_ptr())), "BIZ UDPGothic Regular first");
assert!(fonts.font_data.get(&name).is_some_and(|d| std::ptr::eq(d.bytes().as_ptr(), ui_font.bytes.as_ptr())), "BIZ UDPGothic Regular first");
for (fam, stack) in &fonts.families {
assert_eq!(stack.last(), Some(&name), "{fam:?}: appended last so Latin keeps Inter");
assert!(stack.first().is_some_and(|f| f != &name), "{fam:?}");
@@ -898,6 +915,7 @@ mod tests {
scripts: |_| Vec::new(),
covers: scripts::covering_face,
embedded: no_embedded,
eager_craft_fonts: false,
},
);
assert!(render(&ctx, "Layer 1 – café"));
+2 -2
View File
@@ -105,9 +105,9 @@ pub fn show(ui: &mut egui::Ui, doc: &Document, actions: &mut Vec<(String, Value)
let Some(p) = hover.filter(|_| alt && ctx.dragged_id().is_none()) else { return };
let Some(line) = at(doc, &crate::layer_row_ui::recorded(&ctx), p).filter(|l| ui.rect_contains_pointer(l.rect)) else { return };
// Registered after the rows, so it is on top of them and gets the click.
let resp = ui.interact(line.rect, ui.id().with("clip-line"), Sense::click());
let resp = ui.interact(line.rect, ui.scope_id().with("clip-line"), Sense::click());
let label = if line.release { "Release Clipping Mask" } else { "Create Clipping Mask" };
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
// PhotoCraft's own cursors: a hooked arrow onto the layer below, and the same arrow with a
// small "no" badge to release. The arrow's tip, (15, 16) of the 24-unit box, is the hot spot.
let icon = if line.release { "clip-release" } else { "clip-below" };
+25 -16
View File
@@ -129,7 +129,13 @@ fn scroll_to_adjustment_control(h: &mut Harness<'_, PhotocraftApp>, label: &str)
}
// The label column stays clear of the Color Lookup picker, which consumes wheel input.
h.hover_at(pos2(16.0, 80.0));
h.event(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -80.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE });
h.event(Event::MouseWheel {
source: egui::MouseWheelSource::Trackpad,
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -80.0),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
});
h.run_steps(4);
}
panic!("adjustment control {label:?} was not reachable in the compact viewport");
@@ -199,18 +205,17 @@ fn write_adjustment_scope_preview() {
std::fs::create_dir_all(&output).unwrap();
for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] {
let mut h = adjustment_harness("Hue/Saturation", 8, theme, 2.0, true);
h.ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default()));
// egui 0.37 completes screenshots through a one-shot callback.
let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
let slot = captured.clone();
h.ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::ScreenshotCallback::new(move |image| {
if let Ok(mut slot) = slot.lock() {
*slot = Some(image);
}
})));
h.step();
h.step();
let image = h
.ctx
.input(|input| {
input.events.iter().find_map(|event| match event {
Event::Screenshot { image, .. } => Some(image.clone()),
_ => None,
})
})
.unwrap();
let image = captured.lock().ok().and_then(|mut c| c.take()).expect("the kittest renderer completes the screenshot callback");
let rgba = image.pixels.iter().flat_map(|pixel| pixel.to_array()).collect();
let image = photocraft_codecs::Image::from_u8(image.size[0] as u32, image.size[1] as u32, photocraft_codecs::ChannelLayout::Rgba, rgba).unwrap();
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
@@ -229,13 +234,17 @@ fn write_clipping_indicator_preview() {
for scale in [1.0, 2.0] {
let mut h = harness(theme, width, scale, true);
alt_click_line(&mut h);
h.ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default()));
// egui 0.37 completes screenshots through a one-shot callback.
let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
let slot = captured.clone();
h.ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::ScreenshotCallback::new(move |image| {
if let Ok(mut slot) = slot.lock() {
*slot = Some(image);
}
})));
h.step();
h.step();
let image = h
.ctx
.input(|input| input.events.iter().find_map(|event| if let Event::Screenshot { image, .. } = event { Some(image.clone()) } else { None }))
.unwrap();
let image = captured.lock().ok().and_then(|mut c| c.take()).expect("the kittest renderer completes the screenshot callback");
let rgba = image.pixels.iter().flat_map(|pixel| pixel.to_array()).collect();
let image =
photocraft_codecs::Image::from_u8(image.size[0] as u32, image.size[1] as u32, photocraft_codecs::ChannelLayout::Rgba, rgba).unwrap();
+15 -15
View File
@@ -129,12 +129,12 @@ pub(crate) fn set_color(app: &mut PhotocraftApp, ctx: &egui::Context, background
/// The key of the slider values last shown for `model`, kept while they still give the colour:
/// a grey has no hue, and CMYK or Lab values don't survive an sRGB round trip (#3228).
fn values_key(model: Model, background: bool) -> egui::Id {
egui::Id::new(("color-panel-values", model, background))
egui::Id::unique(("color-panel-values", model, background))
}
/// The key of the Hue Cube's remembered hue and saturation (see [`field_hsva`]).
fn field_key(background: bool) -> egui::Id {
egui::Id::new(("color-field-hue", background))
egui::Id::unique(("color-field-hue", background))
}
/// Slider values for the colour the panel edits: the ones last shown while they still give it.
@@ -192,7 +192,7 @@ pub(crate) fn rgb32(c: [f32; 3]) -> Color32 {
fn slider_rows(ui: &mut egui::Ui, model: Model, v: [f32; 4], background: bool, dynamic: bool) -> Option<[f32; 4]> {
let t = Tokens::get(ui.ctx());
// Dynamic Color Sliders off: the tracks keep the colours they had when a drag began.
let stops_key = egui::Id::new(("color-panel-stops", model, background));
let stops_key = egui::Id::unique(("color-panel-stops", model, background));
let dragging = ui.input(|i| i.pointer.any_down());
let stops_from = if dynamic || !dragging { v } else { ui.data(|d| d.get_temp::<[f32; 4]>(stops_key)).unwrap_or(v) };
if !dragging {
@@ -215,7 +215,7 @@ fn slider_rows(ui: &mut egui::Ui, model: Model, v: [f32; 4], background: bool, d
changed |= track(ui, tr, &resp, &mut x, lo, hi, &stops, model == Model::Web);
resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(x), label));
if model == Model::Web {
changed |= hex_pair(ui, ui.id().with(("web-field", i, background)), &mut x);
changed |= hex_pair(ui, ui.scope_id().with(("web-field", i, background)), &mut x);
} else if widgets::value_field(ui, &mut x, lo..=hi, "", FIELD_W).changed() {
changed = true;
}
@@ -366,8 +366,8 @@ fn alerts(app: &mut PhotocraftApp, ui: &mut egui::Ui, col: Rect, rgb: [f32; 3],
}
ui.painter().rect_filled(chip, 0.0, rgb32(near));
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Outside);
let resp = ui.interact(icon.union(chip), ui.id().with(("color-panel-alert", slot)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tip));
let resp = ui.interact(icon.union(chip), ui.scope_id().with(("color-panel-alert", slot)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tip));
if resp.on_hover_text(tl!(tip)).clicked() {
set_color(app, ui.ctx(), background, near);
}
@@ -411,7 +411,7 @@ fn ramp_colors(ui: &egui::Ui, ramp: ColorRamp, web_safe: bool, fg: [f32; 3], bg:
convert::active_cmyk_space().map(|s| Arc::as_ptr(&s) as usize).hash(&mut h);
}
let sig = h.finish();
let key = egui::Id::new("color-panel-ramp-cache");
let key = egui::Id::unique("color-panel-ramp-cache");
if let Some((s, colors)) = ui.data(|d| d.get_temp::<(u64, Arc<Vec<Color32>>)>(key))
&& s == sig
{
@@ -475,7 +475,7 @@ fn ramp(app: &mut PhotocraftApp, ui: &mut egui::Ui, height: f32) {
let bar = Rect::from_min_max(all.min, pos2(all.right() - BOXES_W - 2.0, all.bottom()));
let boxes = Rect::from_min_max(pos2(all.right() - BOXES_W, all.top()), all.max);
let cp = app.ui.color_panel;
let ramp_id = ui.id().with("color-panel-ramp");
let ramp_id = ui.scope_id().with("color-panel-ramp");
// A pick changes the foreground or background, so a drag keeps the Current Colors ends it began
// with; otherwise the ramp would slide under the pointer and a held pick would drift.
let ends_id = ramp_id.with("ends");
@@ -495,7 +495,7 @@ fn ramp(app: &mut PhotocraftApp, ui: &mut egui::Ui, height: f32) {
ui.painter().rect_filled(black, 0.0, Color32::BLACK);
ui.painter().rect_stroke(boxes, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
let resp = ui.interact(bar, ramp_id, Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), "Color Ramp"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, ui.is_enabled(), "Color Ramp"));
let mods = ui.input(|i| i.modifiers);
let target = cp.background != mods.alt;
if resp.clicked() && mods.shift {
@@ -520,8 +520,8 @@ fn ramp(app: &mut PhotocraftApp, ui: &mut egui::Ui, height: f32) {
ramp_menu(app, ui);
});
for (r, c, name) in [(white, [1.0; 3], "White"), (black, [0.0; 3], "Black")] {
let b = ui.interact(r, ui.id().with(("color-panel-box", name)), Sense::click());
b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), name));
let b = ui.interact(r, ui.scope_id().with(("color-panel-box", name)), Sense::click());
b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name));
if b.clicked() {
set_color(app, ui.ctx(), target, c);
}
@@ -560,7 +560,7 @@ fn wheel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let w = ui.available_width();
let side = fill.filter(|f| f.is_finite()).map_or(w.min(220.0), |f| (f - top_h - gaps).min(w)).max(80.0);
let (area, resp) = ui.allocate_exact_size(vec2(w, side), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), "Color Wheel"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, ui.is_enabled(), "Color Wheel"));
let (center, inner, outer) = wheel_geometry(area);
let ring_w = outer - inner;
let (h, s, b) = (v[0] / 360.0, v[1] / 100.0, v[2] / 100.0);
@@ -570,7 +570,7 @@ fn wheel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Which part a drag began on: the ring sets the hue, the triangle saturation and brightness.
// The ring or the triangle, from where the press began (kept for frames without a press
// origin), so a drag that leaves the ring keeps turning the hue.
let drag_key = ui.id().with("color-wheel-drag");
let drag_key = ui.scope_id().with("color-wheel-drag");
if (resp.dragged() || resp.clicked())
&& let Some(p) = resp.interact_pointer_pos()
{
@@ -616,8 +616,8 @@ fn wheel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.painter().rect_stroke(add, 1.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
ui.painter().line_segment([pos2(add.center().x, add.top() + 3.5), pos2(add.center().x, add.bottom() - 3.5)], Stroke::new(1.0, t.text_dim));
ui.painter().line_segment([pos2(add.left() + 3.5, add.center().y), pos2(add.right() - 3.5, add.center().y)], Stroke::new(1.0, t.text_dim));
let add_resp = ui.interact(add, ui.id().with("color-wheel-add"), Sense::click());
add_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Add to Swatches"));
let add_resp = ui.interact(add, ui.scope_id().with("color-wheel-add"), Sense::click());
add_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Add to Swatches"));
if add_resp.on_hover_text(tl!("Create new swatch")).clicked()
&& let Err(e) = app.run("swatches.add", json!({"color": hex(rgb)}))
{
+16 -16
View File
@@ -438,7 +438,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) -> Option<bool> {
// Under the colour field, not in the action column: the dialog must still fit a
// 760 x 480 window.
ui.add_space(4.0);
if let Some(rgb) = crate::screen_picker::button(ui, ui.id().with("screen-color")) {
if let Some(rgb) = crate::screen_picker::button(ui, ui.scope_id().with("screen-color")) {
set_rgb(f, rgb, Keep::Nothing);
}
});
@@ -543,8 +543,8 @@ fn swatches(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let cur = Rect::from_min_size(sw.min + vec2(0.0, 32.0), vec2(56.0, 32.0));
p.rect_filled(cur, 0.0, c32(orig));
p.rect_stroke(sw, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
let click_cur = ui.interact(cur, ui.id().with("cp-current"), Sense::click());
click_cur.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Click to restore the current colour")));
let click_cur = ui.interact(cur, ui.scope_id().with("cp-current"), Sense::click());
click_cur.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Click to restore the current colour")));
if click_cur.on_hover_text(tl!("Click to restore the current colour")).clicked() {
set_rgb(f, orig, Keep::Nothing);
}
@@ -565,8 +565,8 @@ fn swatches(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
}
p.rect_filled(chip, 0.0, c32(near));
p.rect_stroke(chip, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Outside);
let resp = ui.interact(icon.union(chip), ui.id().with(("cp-warning", slot as i32)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tip));
let resp = ui.interact(icon.union(chip), ui.scope_id().with(("cp-warning", slot as i32)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tip));
if resp.on_hover_text(tip).clicked() {
set_rgb(f, near, Keep::Nothing);
}
@@ -605,7 +605,7 @@ fn radio(ui: &mut egui::Ui, on: bool, label: &str) -> bool {
if on {
ui.painter().circle_filled(r.center(), 3.0, t.accent);
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, ui.is_enabled(), on, label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), on, label));
resp.clicked()
}
@@ -630,7 +630,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, mode: &str, now: &Compo
}
ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim));
let mut v = vals[i].round();
if widgets::value_field(ui, &mut v, ranges[i].clone(), "", VALUE_W).changed() {
if widgets::value_field(ui, &mut v, ranges[i].clone(), "", VALUE_W).accessible_name(tl!(&label)).changed() {
let (mut h, mut c) = (hsv, rgb);
if i < 3 {
h[i] = if i == 0 { v } else { v / 100.0 };
@@ -654,7 +654,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, mode: &str, now: &Compo
ui.label(egui::RichText::new("#").color(t.text_dim));
// While the field has focus it shows what is being typed, not the colour: rebuilding it from
// the colour every frame would throw away every keystroke until six valid digits were in.
let id = ui.id().with("cp-hex");
let id = ui.scope_id().with("cp-hex");
crate::field_tab::register(ui.ctx(), id);
if !f.contains_key("__focused") {
f.insert("__focused".into(), json!(true));
@@ -675,7 +675,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, mode: &str, now: &Compo
_ => shown.trim_start_matches('#').to_ascii_uppercase(),
},
};
let r = crate::widgets::text_field_ex(ui, id, &mut h, "", 64.0, true, Some(6));
let r = crate::widgets::text_field_ex(ui, id, &mut h, "", 64.0, true, Some(6)).accessible_name(tl!("Color"));
if let Some(c) = pasted {
edit = Some((c, Keep::Nothing));
}
@@ -702,7 +702,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, mode: &str, now: &Compo
}
ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim));
let mut x = v.round();
if widgets::value_field(ui, &mut x, range, "", VALUE_W).changed() {
if widgets::value_field(ui, &mut x, range, "", VALUE_W).accessible_name(tl!(&label)).changed() {
let mut l = lab;
l[i] = x;
edit = Some((photocraft_color::convert::lab_to_srgb(l).map(|c| c.clamp(0.0, 1.0)), Keep::Lab(l)));
@@ -716,7 +716,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, mode: &str, now: &Compo
ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim));
let mut x = (cmyk[i] * 100.0).round();
if widgets::value_field(ui, &mut x, 0.0..=100.0, "", VALUE_W).changed() {
if widgets::value_field(ui, &mut x, 0.0..=100.0, "", VALUE_W).accessible_name(tl!(&label)).changed() {
let mut k = cmyk;
k[i] = x / 100.0;
edit = Some((photocraft_color::convert::cmyk_to_rgb(k).map(|c| c.clamp(0.0, 1.0)), Keep::Cmyk(k)));
@@ -893,14 +893,14 @@ mod tests {
ui.horizontal(|ui| {
let o = ui.cursor().min;
let gap = ui.spacing().item_spacing.x;
ui.data_mut(|d| d.insert_temp(egui::Id::new("test-origin"), (o, gap)));
ui.data_mut(|d| d.insert_temp(egui::Id::unique("test-origin"), (o, gap)));
color_field(ui, f);
});
},
f,
);
h.run_steps(2);
let (o, gap) = h.ctx.data(|d| d.get_temp::<(egui::Pos2, f32)>(egui::Id::new("test-origin"))).unwrap();
let (o, gap) = h.ctx.data(|d| d.get_temp::<(egui::Pos2, f32)>(egui::Id::unique("test-origin"))).unwrap();
// Field: saturation across, brightness down (hue mode).
let at = o + vec2(FIELD * 0.5, FIELD * 0.5);
press(&mut h, at);
@@ -938,14 +938,14 @@ mod tests {
ui.horizontal(|ui| {
let o = ui.cursor().min;
let gap = ui.spacing().item_spacing.x;
ui.data_mut(|d| d.insert_temp(egui::Id::new("test-origin"), (o, gap)));
ui.data_mut(|d| d.insert_temp(egui::Id::unique("test-origin"), (o, gap)));
color_field(ui, f);
});
},
f,
);
h.run_steps(2);
let (o, gap) = h.ctx.data(|d| d.get_temp::<(egui::Pos2, f32)>(egui::Id::new("test-origin"))).unwrap();
let (o, gap) = h.ctx.data(|d| d.get_temp::<(egui::Pos2, f32)>(egui::Id::unique("test-origin"))).unwrap();
for at in [o + vec2(FIELD * 0.5, FIELD * 0.5), o + vec2(FIELD + gap + 9.0, FIELD * 0.95)] {
for button in [egui::PointerButton::Secondary, egui::PointerButton::Middle] {
press_button(&mut h, at, button, true);
@@ -1122,7 +1122,7 @@ mod tests {
h.run_steps(4);
let id = open(h.state_mut(), "foreground");
h.run_steps(6);
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).expect("the picker window");
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", id)))).expect("the picker window");
assert!(rect.width() <= 534.0 && rect.height() <= 376.0, "Color Picker window {} x {} points, Photoshop's 534 x 376", rect.width(), rect.height());
// Everything still shows: the three actions and all 13 component fields.
for label in ["OK", "Cancel", "Add to Swatches", "Only Web Colors"] {
+16 -15
View File
@@ -408,7 +408,7 @@ fn eyedropper(ui: &mut egui::Ui, badge: &str, selected: bool, label: &str) -> eg
let at = r.rect.right_bottom() - vec2(5.0, 6.0);
ui.painter().text(at, egui::Align2::CENTER_CENTER, badge, crate::theme::semibold(12.0), if selected { t.accent_text } else { t.text });
}
r.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, label));
r.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, label));
r
}
@@ -424,7 +424,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Select:")).color(t.text_dim));
let mut cur: &str = &select;
if widgets::dropdown(ui, "color-range-select", &mut cur, SELECTS, 170.0) {
if widgets::dropdown(ui, "color-range-select", tl!("Select"), &mut cur, SELECTS, 170.0) {
f.insert("select".into(), json!(cur));
}
});
@@ -512,7 +512,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
Some(e) => format!("Color Range preview unavailable: {e}"),
None => tl!("Color Range preview").to_string(),
};
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), &label));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, ui.is_enabled(), &label));
ui.add_space(10.0);
ui.vertical(|ui| {
ui.add_enabled_ui(c.sampling, |ui| {
@@ -566,7 +566,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
ui.label(egui::RichText::new(tl!("Selection Preview:")).color(t.text_dim));
let mode = s(f, "__selectionPreview", "none").to_string();
let mut chosen = mode.as_str();
if widgets::dropdown(ui, "color-range-selection-preview", &mut chosen, PREVIEWS, 170.0) {
if widgets::dropdown(ui, "color-range-selection-preview", tl!("Preview"), &mut chosen, PREVIEWS, 170.0) {
f.insert("__selectionPreview".into(), json!(chosen));
}
});
@@ -685,7 +685,7 @@ pub fn canvas_eyedropper(app: &mut PhotocraftApp, ctx: &egui::Context, over: egu
_ => "",
};
if !badge.is_empty() {
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("pc-color-range-badge")));
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("pc-color-range-badge")));
let at = over + vec2(20.0, -4.0);
p.text(at + vec2(1.0, 1.0), egui::Align2::CENTER_CENTER, badge, crate::theme::semibold(13.0), Color32::from_black_alpha(200));
p.text(at, egui::Align2::CENTER_CENTER, badge, crate::theme::semibold(13.0), Color32::WHITE);
@@ -807,7 +807,8 @@ mod tests {
}
fn disabled(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool {
h.get_by_label(label).accesskit_node().is_disabled()
// A slider row names both its number and its slider with the caption; either reports it.
h.query_all_by_label(label).next().unwrap_or_else(|| panic!("`{label}`")).accesskit_node().is_disabled()
}
#[test]
@@ -906,7 +907,7 @@ mod tests {
h.run_steps(3);
assert!(h.state().ui.dialogs.iter().any(|d| owns(&d.fields)), "Select › Color Range… must open the dialog");
for label in ["Color Range", "Fuzziness:", "Localized Color Clusters", "Invert", "Selection", "Color Range preview", "Add to Sample"] {
assert!(h.query_by_label(label).is_some(), "missing {label}");
assert!(h.query_all_by_label(label).next().is_some(), "missing {label}");
}
assert_eq!(h.query_all_by_label("Image").count(), 2, "the Image menu and the Image preview radio");
assert_eq!(h.state().session.active().unwrap().revision, rev, "opening must not edit the document");
@@ -923,23 +924,23 @@ mod tests {
h.run_steps(4);
// Sampled Colors: Fuzziness, eyedroppers and Localized Color Clusters; Range only when localized.
assert!(!disabled(&h, "Fuzziness:") && !disabled(&h, "Localized Color Clusters") && !disabled(&h, "Add to Sample"));
assert!(h.query_by_label("Range:").is_none());
assert!(h.query_all_by_label("Range:").next().is_none());
h.get_by_label("Localized Color Clusters").click();
h.run_steps(2);
assert!(h.query_by_label("Range:").is_some(), "Range shows with Localized Color Clusters");
assert!(h.query_all_by_label("Range:").next().is_some(), "Range shows with Localized Color Clusters");
// A hue family: no Fuzziness, eyedroppers or clusters (Photoshop disables them).
set(&mut h, "select", json!("reds"));
assert!(disabled(&h, "Fuzziness:") && disabled(&h, "Localized Color Clusters") && disabled(&h, "Add to Sample"));
assert!(h.query_by_label("Range:").is_none());
assert!(h.query_all_by_label("Range:").next().is_none());
assert!(!disabled(&h, "Invert"));
// Tones: Fuzziness and the tonal range.
set(&mut h, "select", json!("shadows"));
assert!(!disabled(&h, "Fuzziness:") && disabled(&h, "Localized Color Clusters"));
assert!(h.query_by_label("Shadows up to:").is_some());
assert!(h.query_all_by_label("Shadows up to:").next().is_some());
set(&mut h, "select", json!("midtones"));
assert!(h.query_by_label("Midtones from:").is_some() && h.query_by_label("Midtones to:").is_some());
assert!(h.query_all_by_label("Midtones from:").next().is_some() && h.query_all_by_label("Midtones to:").next().is_some());
set(&mut h, "select", json!("outOfGamut"));
assert!(disabled(&h, "Fuzziness:") && h.query_by_label("Midtones from:").is_none());
assert!(disabled(&h, "Fuzziness:") && h.query_all_by_label("Midtones from:").next().is_none());
// Skin Tones: Fuzziness (the colour one), no eyedroppers or clusters.
set(&mut h, "select", json!("skinTones"));
assert!(!disabled(&h, "Fuzziness:") && disabled(&h, "Localized Color Clusters") && disabled(&h, "Add to Sample"));
@@ -956,7 +957,7 @@ mod tests {
let mut h = harness(app_with_doc());
open(h.state_mut());
h.run_steps(4);
let area = egui::Id::new(("dialog", dialog_id(h.state())));
let area = egui::Id::unique(("dialog", dialog_id(h.state())));
let rect = |h: &Harness<'static, PhotocraftApp>| h.ctx.memory(|m| m.area_rect(area)).unwrap_or(egui::Rect::NOTHING);
set(&mut h, "select", json!("outOfGamut"));
let short = rect(&h);
@@ -1273,7 +1274,7 @@ mod tests {
h.get_by_label("Color Range…").click();
h.run_steps(6);
let id = dialog_id(h.state());
let area = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).expect("the dialog is drawn");
let area = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", id)))).expect("the dialog is drawn");
assert!(h.ctx.content_rect().contains_rect(area), "the dialog is on screen: {area:?}");
assert!(h.query_by_label("Color Range preview").is_some());
let p = h.state().color_range.as_ref().expect("the preview is cached");
+2 -2
View File
@@ -9,7 +9,7 @@ use crate::dock::Group;
use crate::theme::{ThemeKind, Tokens};
fn left_top() -> egui::Id {
egui::Id::new("color-tab-test-origin")
egui::Id::unique("color-tab-test-origin")
}
/// The Color tab of `kind`, alone in a 300 pt wide panel, starting from a red foreground. `fill`
@@ -59,7 +59,7 @@ fn every_theme_shows_the_same_spectrum_field() {
assert!((r - g).abs() < 0.02 && (g - b).abs() < 0.02 && (0.3..0.7).contains(&r), "{kind:?}: the field picks a mid grey, got {r} {g} {b}");
// No theme swaps the field for sliders or adds them under it (#6000).
for slider in ["Hue", "Saturation", "Brightness"] {
assert!(h.query_by_label(slider).is_none(), "{kind:?}: no {slider} slider");
assert!(h.query_all_by_label(slider).next().is_none(), "{kind:?}: no {slider} slider");
}
}
}
+3 -3
View File
@@ -36,7 +36,7 @@ struct RenameComp {
fn rename_field(ui: &mut egui::Ui, id: egui::Id, rect: Rect) -> Option<String> {
let mut r = ui.data(|d| d.get_temp::<RenameComp>(id))?;
ui.data_mut(|d| d.remove::<RenameComp>(id));
let te = ui.put(rect, egui::TextEdit::singleline(&mut r.text).id(id).font(egui::FontId::proportional(12.0)));
let te = ui.put(rect, egui::TextEdit::singleline(&mut r.text).id(id).font(egui::FontId::proportional(12.0))).accessible_name(tl!("Name"));
if !r.focused {
te.request_focus();
// Like Photoshop, the whole name starts selected so typing replaces it.
@@ -126,7 +126,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
for (index, c) in doc.layer_comps.iter().enumerate() {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected == Some(c.id), &c.name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected == Some(c.id), &c.name));
resp.dnd_set_drag_payload(DragComp { document: doc.id, comp: c.id });
if let Some(source) = resp.dnd_hover_payload::<DragComp>()
&& source.document == doc.id
@@ -213,7 +213,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let name = ui.painter().layout(c.name.clone(), egui::FontId::proportional(12.0), t.text, galley_w);
let name_rect = Rect::from_min_size(pos2(name_x, rect.center().y - name.size().y / 2.0), name.size());
// Comp ids restart in every document, so the pending name is keyed by the document too.
let rename_id = egui::Id::new(("comp-rename", doc.id.0, c.id));
let rename_id = egui::Id::unique(("comp-rename", doc.id.0, c.id));
if ui.data(|d| d.get_temp::<RenameComp>(rename_id)).is_some() {
let field = Rect::from_min_max(pos2(name_x - 3.0, rect.top() + 3.0), pos2((x + 10.0).max(name_x + 60.0), rect.bottom() - 3.0));
if let Some(text) = rename_field(ui, rename_id, field) {
+11 -3
View File
@@ -1285,8 +1285,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
if focus.unwrap_or(true) {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
// The capture itself is issued by `PhotocraftApp::issue_screenshots` once open/close
// animations (modals, popups) have settled.
// The capture itself is admitted by `PhotocraftApp::drain_control` (budget and
// geometry) and issued from a later `App::ui` pass once open/close animations
// (modals, popups) have settled (see `screenshot.rs`).
let token = app.ui.alloc_id();
ctx.request_repaint();
Outcome::Screenshot { token, path }
@@ -1424,8 +1425,15 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
pub fn save_screenshot(app: &mut PhotocraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
let [w, h] = image.size;
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
save_screenshot_rgba(app, w, h, &rgba, path)
}
/// Encode already-flattened RGBA pixels. The capture lifecycle uses this after dropping the
/// retained [`egui::ColorImage`], so the encoder's input copy and the retained image do not
/// overlap in memory (see `PhotocraftApp::finish_captures`).
pub fn save_screenshot_rgba(app: &mut PhotocraftApp, w: usize, h: usize, rgba: &[u8], path: Option<&str>) -> Value {
let png = match app.services.encode_png.as_ref() {
Some(enc) => enc(w as u32, h as u32, &rgba),
Some(enc) => enc(w as u32, h as u32, rgba),
None => Err("no PNG encoder configured".into()),
};
let Some(path) = path else {
+3 -2
View File
@@ -254,7 +254,7 @@ pub fn contributors_ui(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
/// [`contributors_ui`] over a given list (tests pass their own).
fn contributors_in(app: &mut PhotocraftApp, ui: &mut egui::Ui, contributors: &[Contributor], total_commits: u64) {
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(tl!("Show"));
@@ -276,6 +276,7 @@ fn contributors_in(app: &mut PhotocraftApp, ui: &mut egui::Ui, contributors: &[C
}
});
press.after(&sort.response);
let _ = sort.response.accessible_name(tl!("Sort"));
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(tl!("Reverse the order")).clicked() {
v.ascending = !v.ascending;
}
@@ -453,7 +454,7 @@ fn cell(ui: &mut egui::Ui, numeric: bool, text: &str) {
fn header_cell(ui: &mut egui::Ui, t: &crate::theme::Tokens, label: &str, numeric: bool, sort: Option<Option<bool>>) -> egui::Response {
let rect = ui.max_rect();
let resp = ui.allocate_rect(rect, if sort.is_some() { egui::Sense::click() } else { egui::Sense::hover() });
let kind = if sort.is_some() { egui::WidgetType::Button } else { egui::WidgetType::Label };
let kind = if sort.is_some() { egui::Role::Button } else { egui::Role::Label };
resp.widget_info(|| egui::WidgetInfo::labeled(kind, true, label));
let resp = if sort.is_some() { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } else { resp };
let current = sort.flatten();
+4 -3
View File
@@ -40,7 +40,7 @@ fn harness(list: Vec<Contributor>, table: bool) -> Harness<'static> {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) {
return;
}
let id = egui::Id::new("credits_view");
let id = egui::Id::unique("credits_view");
if ui.data(|d| d.get_temp::<View>(id)).is_none() {
ui.data_mut(|d| d.insert_temp(id, View { table, ..View::default() }));
}
@@ -71,7 +71,7 @@ fn text_rect(h: &Harness<'_>, text: &str) -> Option<Rect> {
}
fn view(h: &Harness<'_>) -> View {
h.ctx.data(|d| d.get_temp::<View>(egui::Id::new("credits_view"))).unwrap_or_default()
h.ctx.data(|d| d.get_temp::<View>(egui::Id::unique("credits_view"))).unwrap_or_default()
}
#[test]
@@ -83,6 +83,7 @@ fn the_table_header_stays_put_while_the_rows_scroll() {
h.event(egui::Event::PointerMoved(over));
for _ in 0..6 {
h.event(egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -200.0),
modifiers: egui::Modifiers::NONE,
@@ -210,7 +211,7 @@ fn about_window(tab: &str, table: bool) -> (f32, f32, f32) {
let mut fields = serde_json::Map::new();
fields.insert("tab".into(), serde_json::json!(tab));
h.state_mut().ui.open_dialog(crate::state::DialogKind::About, fields);
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("credits_view"), View { table, ..View::default() }));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("credits_view"), View { table, ..View::default() }));
h.run_steps(6);
let ok = h.query_all_by_role(Role::Button).find(|n| n.accesskit_node().label().as_deref() == Some("OK")).map(|n| n.rect()).expect("the OK button");
// The dialog's contents: everything between its title and the OK button.
+28 -26
View File
@@ -143,32 +143,34 @@ pub fn dropdown(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let can_new = savable(o).is_some();
let mut chosen: Option<Choice> = None;
let parent_layer = ui.layer_id();
egui::ComboBox::from_id_salt("crop-ratio").selected_text(label).width(140.0).height(420.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
crate::press_menu::above_parent_ui(parent_layer, ui);
let (before, after): (Vec<_>, Vec<_>) = builtin.iter().partition(|(k, _)| k != crop_size::FRONT);
for (k, l) in &before {
if ui.selectable_label(current_user.is_none() && *k == o.crop_ratio, tl!(l)).clicked() {
chosen = Some(Choice::Builtin(k.clone()));
let combo =
egui::ComboBox::from_id_salt("crop-ratio").selected_text(label).width(140.0).height(420.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
crate::press_menu::above_parent_ui(parent_layer, ui);
let (before, after): (Vec<_>, Vec<_>) = builtin.iter().partition(|(k, _)| k != crop_size::FRONT);
for (k, l) in &before {
if ui.selectable_label(current_user.is_none() && *k == o.crop_ratio, tl!(l)).clicked() {
chosen = Some(Choice::Builtin(k.clone()));
}
}
}
for (name, _) in &users {
if ui.selectable_label(current_user.as_deref() == Some(name), name.as_str()).clicked() {
chosen = Some(Choice::User(name.clone()));
for (name, _) in &users {
if ui.selectable_label(current_user.as_deref() == Some(name), name.as_str()).clicked() {
chosen = Some(Choice::User(name.clone()));
}
}
}
for (k, l) in &after {
if ui.selectable_label(false, tl!(l)).clicked() {
chosen = Some(Choice::Builtin(k.clone()));
for (k, l) in &after {
if ui.selectable_label(false, tl!(l)).clicked() {
chosen = Some(Choice::Builtin(k.clone()));
}
}
}
ui.separator();
if ui.add_enabled(can_new, egui::Button::selectable(false, tl!("New Crop Preset…"))).clicked() {
chosen = Some(Choice::New);
}
if ui.add_enabled(!users.is_empty(), egui::Button::selectable(false, tl!("Delete Crop Preset…"))).clicked() {
chosen = Some(Choice::Delete);
}
});
ui.separator();
if ui.add_enabled(can_new, egui::Button::selectable(false, tl!("New Crop Preset…"))).clicked() {
chosen = Some(Choice::New);
}
if ui.add_enabled(!users.is_empty(), egui::Button::selectable(false, tl!("Delete Crop Preset…"))).clicked() {
chosen = Some(Choice::Delete);
}
});
let _ = combo.response.accessible_name(tl!("Aspect Ratio"));
match chosen {
Some(Choice::Builtin(k)) => {
app.ui.tool_options.crop_ratio = k;
@@ -265,10 +267,10 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map<String, Val
fn new_body(ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
let mut name = fields.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
let id = ui.id().with("crop-preset-name");
let id = ui.scope_id().with("crop-preset-name");
ui.horizontal(|ui| {
ui.label(tl!("Name:"));
let r = ui.add(egui::TextEdit::singleline(&mut name).id(id).char_limit(MAX_NAME).desired_width(260.0));
let name_label = ui.label(tl!("Name:"));
let r = ui.add(egui::TextEdit::singleline(&mut name).id(id).char_limit(MAX_NAME).desired_width(260.0)).labelled_by(name_label.id);
// Photoshop focuses the name with all of it selected, so typing replaces it.
let once = id.with("focused");
if !ui.ctx().data(|d| d.get_temp::<bool>(once).unwrap_or(false)) {
+6 -3
View File
@@ -277,7 +277,7 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
/// normalised into `value` (by `normalize(typed, previous)`) when it loses it. Returns true then.
fn text_field(ui: &mut egui::Ui, id: &str, value: &mut String, hint: &str, width: f32, normalize: fn(&str, &str) -> String) -> bool {
let t = Tokens::get(ui.ctx());
let id = ui.id().with(id);
let id = ui.scope_id().with(id);
let mut buf = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| value.clone());
let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), egui::Sense::hover());
crate::widgets::surface(ui, rect, t.field, false);
@@ -285,7 +285,10 @@ fn text_field(ui: &mut egui::Ui, id: &str, value: &mut String, hint: &str, width
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
}
let inner = rect.shrink2(vec2(4.0, 2.0));
let r = ui.put(inner, egui::TextEdit::singleline(&mut buf).id(id).char_limit(32).hint_text(hint).frame(egui::Frame::NONE).font(crate::theme::mono(12.0)));
let r = ui.put(
inner,
egui::TextEdit::singleline(&mut buf).id(id).char_limit(32).hint_text(hint.to_owned()).frame(egui::Frame::NONE).font(crate::theme::mono(12.0)),
);
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(id, buf));
return false;
@@ -310,7 +313,7 @@ pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) {
text_field(ui, "crop-resolution", &mut o.crop_resolution, tl!("Resolution"), 64.0, normalize_resolution);
let units = [(PX_PER_IN.to_string(), "px/in"), (PX_PER_CM.to_string(), "px/cm")];
let before = o.crop_resolution_unit.clone();
if crate::widgets::dropdown(ui, "crop-resolution-unit", &mut o.crop_resolution_unit, &units, 64.0) && before != o.crop_resolution_unit {
if crate::widgets::dropdown(ui, "crop-resolution-unit", tl!("Resolution"), &mut o.crop_resolution_unit, &units, 64.0) && before != o.crop_resolution_unit {
// The same resolution in the other unit, as in Photoshop.
if let Some(v) = crate::widgets::parse_num(&o.crop_resolution.trim().replace(',', ".")).filter(|v| v.is_finite() && *v > 0.0) {
let v = if o.crop_resolution_unit == PX_PER_CM { v / 2.54 } else { v * 2.54 };
+1 -1
View File
@@ -1006,7 +1006,7 @@ pub fn draw_turn_cursor(ctx: &egui::Context, p: egui::Pos2, toward: egui::Vec2)
}
let u = toward / len;
let tokens = crate::theme::Tokens::get(ctx);
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("crop-turn-cursor")));
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("crop-turn-cursor")));
const R: f32 = 12.0;
const HALF: f32 = 0.75;
let c = p + u * R;
+1 -1
View File
@@ -69,7 +69,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let Some(p) = &mut app.dds_options else { return };
let (mut ok, mut cancel) = (false, false);
let mut options = p.options;
let modal = egui::Modal::new(egui::Id::new("dds-options")).show(ctx, |ui| {
let modal = egui::Modal::new(egui::Id::unique("dds-options")).show(ctx, |ui| {
ui.set_max_width(340.0);
ui.label(egui::RichText::new(tl!("DDS Options")).font(crate::theme::semibold(15.0)));
ui.add_space(4.0);
+2 -2
View File
@@ -27,14 +27,14 @@ pub fn body(ui: &mut egui::Ui, fields: &mut Map<String, Value>, prefix: &str) {
label(ui, tl!("Mode:"));
let mut mode = fields.get("mode").and_then(Value::as_str).unwrap_or("normal").to_string();
let opts: Vec<(String, &str)> = photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (mode_key(*m), m.label())).collect();
if crate::widgets::dropdown(ui, &format!("{prefix}-mode"), &mut mode, &opts, 170.0) {
if crate::widgets::dropdown(ui, &format!("{prefix}-mode"), tl!("Blend Mode"), &mut mode, &opts, 170.0) {
fields.insert("mode".into(), json!(mode));
}
});
ui.horizontal(|ui| {
label(ui, tl!("Opacity:"));
let mut opacity = fields.get("opacity").and_then(Value::as_f64).unwrap_or(100.0) as f32;
if crate::widgets::value_field(ui, &mut opacity, 0.0..=100.0, "%", 70.0).changed() {
if crate::widgets::value_field(ui, &mut opacity, 0.0..=100.0, "%", 70.0).accessible_name(tl!("Opacity:")).changed() {
fields.insert("opacity".into(), json!(opacity.round()));
}
});
+16 -13
View File
@@ -49,7 +49,7 @@ fn fill_field(app: &mut PhotocraftApp, id: u64, key: &str, path: String) -> Resu
const RECTS: &str = "pc-dialog-rects";
fn rects(ctx: &egui::Context) -> Vec<egui::Rect> {
ctx.data(|m| m.get_temp(egui::Id::new(RECTS))).unwrap_or_default()
ctx.data(|m| m.get_temp(egui::Id::unique(RECTS))).unwrap_or_default()
}
/// The window drag target must not cover a dialog which has been moved into the title bar.
@@ -62,7 +62,7 @@ pub(crate) fn titlebar_drag_area_is_clear(ctx: &egui::Context, area: egui::Rect)
/// check against the dialog rects alone would take the drag for a canvas drag. Remembered from the
/// frame of the press, which is the only frame that carries the press event.
fn press_began_on_dialog(ctx: &egui::Context, rects: &[egui::Rect]) -> bool {
let key = egui::Id::new("pc-dialog-press-grabbed");
let key = egui::Id::unique("pc-dialog-press-grabbed");
let pressed = ctx.input(|i| {
i.events.iter().rev().find_map(|e| match e {
egui::Event::PointerButton { pos, pressed: true, .. } => Some(*pos),
@@ -106,7 +106,7 @@ pub fn pan_delta(ctx: &egui::Context, canvas: egui::Rect, hand: bool) -> Option<
/// response would report them if it weren't inert: for the held Zoom (⌘Space / ⌥Space, #6460).
/// A drag counts from egui's drag threshold, like `drag_started`.
pub fn free_buttons(ctx: &egui::Context, canvas: egui::Rect) -> crate::paint_mouse::Buttons {
let id = egui::Id::new("pc-dialog-free-drag");
let id = egui::Id::unique("pc-dialog-free-drag");
let was = ctx.data(|d| d.get_temp::<bool>(id)).unwrap_or(false);
let rects = rects(ctx);
let open_popup = egui::Popup::is_any_open(ctx);
@@ -170,15 +170,15 @@ fn first_field_focused<R>(ui: &mut egui::Ui, id: egui::Id, body: impl FnOnce(&mu
/// its own), per kind for the rest.
fn place_id(d: &Dialog) -> egui::Id {
match d.fields.get("__command").and_then(Value::as_str) {
Some(cmd) => egui::Id::new(("dialog-place", cmd)),
None => egui::Id::new(("dialog-place", format!("{:?}", d.kind))),
Some(cmd) => egui::Id::unique(("dialog-place", cmd)),
None => egui::Id::unique(("dialog-place", format!("{:?}", d.kind))),
}
}
/// Whether another dialog is drawn above `live` (egui's layer order, back to front).
fn older_dialog_above(ctx: &egui::Context, live: egui::LayerId, dialogs: &[Dialog]) -> bool {
let others: Vec<egui::LayerId> =
dialogs.iter().map(|d| egui::LayerId::new(egui::Order::Foreground, egui::Id::new(("dialog", d.id)))).filter(|l| *l != live).collect();
dialogs.iter().map(|d| egui::LayerId::new(egui::Order::Foreground, egui::Id::unique(("dialog", d.id)))).filter(|l| *l != live).collect();
ctx.memory(|m| m.layer_ids().skip_while(|l| *l != live).skip(1).any(|l| others.contains(&l)))
}
@@ -286,7 +286,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let mut outcome: Option<bool> = None; // Some(true)=OK, Some(false)=Cancel
let mut apply_requested = false;
let title = display_title(&d);
let id = egui::Id::new(("dialog", d.id));
let id = egui::Id::unique(("dialog", d.id));
// Opens where the same dialog was last left (centred the first time), then its top-left
// stays put (offset from the window's top-left, moved by dragging the title bar; view
// state only, so egui memory): a dialog whose body grows, like Layer Style switching
@@ -681,7 +681,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
}
}
ctx.data_mut(|m| m.insert_temp(egui::Id::new(RECTS), shown));
ctx.data_mut(|m| m.insert_temp(egui::Id::unique(RECTS), shown));
}
/// The About window's tabs, as stored in its `tab` field.
@@ -1122,7 +1122,7 @@ mod tests {
let at_press = egui::Rect::from_min_max(egui::pos2(100.0, 100.0), egui::pos2(300.0, 200.0));
let moved = egui::Rect::from_min_max(egui::pos2(550.0, 480.0), egui::pos2(750.0, 580.0));
let run = |rect: egui::Rect, events: Vec<egui::Event>| {
ctx.data_mut(|m| m.insert_temp(egui::Id::new(RECTS), vec![rect]));
ctx.data_mut(|m| m.insert_temp(egui::Id::unique(RECTS), vec![rect]));
ctx.begin_pass(egui::RawInput { events, ..Default::default() });
let out = (pan_delta(&ctx, canvas, true), free_press(&ctx, canvas));
ctx.end_pass().textures_delta.clear();
@@ -1278,7 +1278,8 @@ mod tests {
assert_eq!(angle(&h), Some(0.0));
// The dial's field is the dialog's first number, so it opens focused (#1757).
let first = h.get_all_by_role(egui::accesskit::Role::SpinButton).next().unwrap();
assert!(first.is_focused() && first.rect().center().y < h.get_by_label("Distance").rect().center().y, "the Angle field has focus");
let distance = h.query_all_by_label("Distance").next().expect("the Distance field").rect();
assert!(first.is_focused() && first.rect().center().y < distance.center().y, "the Angle field has focus");
// Click above the centre: straight up is 90°.
let up = centre(&h) + egui::vec2(0.0, -15.0);
h.event(egui::Event::PointerMoved(up));
@@ -1401,10 +1402,12 @@ mod tests {
let ctx = h.ctx.clone();
let d = crate::menus::invoke(h.state_mut(), &ctx, "file.automate.fitImage", json!({})).unwrap()["dialog"].as_u64().unwrap();
h.run_steps(4);
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", d)))).expect("the Fit Image window");
let window = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", d)))).expect("the Fit Image window");
assert!(window.width() < 300.0, "Fit Image is {} points wide", window.width());
for label in ["Fit Image", "Width", "Height", "OK", "Cancel"] {
assert!(window.contains_rect(h.get_by_label(label).rect()), "{label} inside the window");
// A caption and the field it names share the label; either sits inside the window.
let node = h.query_all_by_label(label).next().unwrap_or_else(|| panic!("{label} in the dialog"));
assert!(window.contains_rect(node.rect()), "{label} inside the window");
}
}
}
@@ -1440,7 +1443,7 @@ mod preference_resize_tests {
}
fn frame(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> egui::Rect {
let rects: Vec<egui::Rect> = h.ctx.data(|m| m.get_temp(egui::Id::new(RECTS))).unwrap();
let rects: Vec<egui::Rect> = h.ctx.data(|m| m.get_temp(egui::Id::unique(RECTS))).unwrap();
rects[0]
}
+2 -2
View File
@@ -68,12 +68,12 @@ fn parse_dir(value: &str) -> Option<Dir> {
}
/// A side panel on the reading-start side: left, or right in a right-to-left UI (the toolbox).
pub fn start_panel(id: impl Into<egui::Id>) -> egui::Panel {
pub fn start_panel(id: impl egui::AsIdSalt) -> egui::Panel {
if is_rtl() { egui::Panel::right(id) } else { egui::Panel::left(id) }
}
/// A side panel on the reading-end side: right, or left in a right-to-left UI (the dock).
pub fn end_panel(id: impl Into<egui::Id>) -> egui::Panel {
pub fn end_panel(id: impl egui::AsIdSalt) -> egui::Panel {
if is_rtl() { egui::Panel::left(id) } else { egui::Panel::right(id) }
}
+1 -1
View File
@@ -312,7 +312,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let labels = button_labels(mac, &buttons.iter().map(|b| (b.1, b.2)).collect::<Vec<_>>());
let row: Vec<DialogButton> =
buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3).focus(focus && b.0 == ButtonRole::Default)).collect();
let modal = egui::Modal::new(egui::Id::new("discard-prompt")).show(ctx, |ui| {
let modal = egui::Modal::new(egui::Id::unique("discard-prompt")).show(ctx, |ui| {
ui.set_max_width(420.0);
ui.label(egui::RichText::new(tl!(&title)).font(crate::theme::semibold(15.0)));
ui.add_space(4.0);
+11 -11
View File
@@ -70,7 +70,7 @@ pub fn canvas_resize_params(old: (u32, u32), new_w: Option<f32>, new_h: Option<f
/// Collapsible section header with a chevron, Photoshop Properties style. Returns whether it is open.
fn section(ui: &mut egui::Ui, id: &str, title: &str) -> bool {
let t = Tokens::get(ui.ctx());
let key = egui::Id::new(("doc-props-section", id));
let key = egui::Id::unique(("doc-props-section", id));
let mut open = ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(true);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
icons::paint(
@@ -96,12 +96,12 @@ fn field_label(ui: &mut egui::Ui, s: &str, w: f32) {
}
/// A number field that only reports a committed value (drag released, Enter, focus lost).
fn committed_field(ui: &mut egui::Ui, id: &str, current: f32, u: crate::layer_props_ui::LenUnit) -> Option<f32> {
let key = egui::Id::new(("doc-props-field", id));
fn committed_field(ui: &mut egui::Ui, id: &str, name: &str, current: f32, u: crate::layer_props_ui::LenUnit) -> Option<f32> {
let key = egui::Id::unique(("doc-props-field", id));
// Shown in the ruler unit (cm when the rulers are in centimeters); 100% is the current size.
let extent = f64::from(current);
let mut v = ui.data(|d| d.get_temp::<f32>(key)).unwrap_or_else(|| u.shown(extent, extent));
let r = widgets::value_field(ui, &mut v, 0.0..=300_000.0, u.unit.suffix(), 74.0);
let r = widgets::value_field(ui, &mut v, 0.0..=300_000.0, u.unit.suffix(), 74.0).accessible_name(tl!(name));
if r.dragged() || r.has_focus() {
ui.data_mut(|d| d.insert_temp(key, v));
return None;
@@ -140,7 +140,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.add_space(2.0);
let label_w = 44.0;
if section(ui, "canvas", tl!("Canvas")) {
let link_key = egui::Id::new("doc-props-link");
let link_key = egui::Id::unique("doc-props-link");
let linked = ui.data(|d| d.get_temp::<bool>(link_key)).unwrap_or(false);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
@@ -153,7 +153,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.vertical(|ui| {
ui.horizontal(|ui| {
stacked_label(ui, "W", &["W", "H"], 18.0, 4.0);
if let Some(v) = committed_field(ui, "w", w as f32, lu) {
if let Some(v) = committed_field(ui, "w", "Width", w as f32, lu) {
run.extend(canvas_resize_params((w, h), Some(v), None, linked).map(|p| ("image.canvasSize".to_string(), p)));
}
stacked_label(ui, "X", &["X", "Y"], 22.0, 4.0);
@@ -161,7 +161,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
});
ui.horizontal(|ui| {
stacked_label(ui, "H", &["W", "H"], 18.0, 4.0);
if let Some(v) = committed_field(ui, "h", h as f32, lu) {
if let Some(v) = committed_field(ui, "h", "Height", h as f32, lu) {
run.extend(canvas_resize_params((w, h), None, Some(v), linked).map(|p| ("image.canvasSize".to_string(), p)));
}
stacked_label(ui, "Y", &["X", "Y"], 22.0, 4.0);
@@ -190,7 +190,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
field_label(ui, tl!("Mode"), label_w);
let mut m = mode;
let opts: Vec<(ColorMode, &str)> = MODES.iter().map(|(m, l, _)| (*m, *l)).collect();
if widgets::dropdown(ui, "doc-props-mode", &mut m, &opts, 150.0)
if widgets::dropdown(ui, "doc-props-mode", tl!("Color Mode"), &mut m, &opts, 150.0)
&& let Some((_, _, id)) = MODES.iter().find(|(x, _, _)| *x == m)
{
run.push(((*id).to_string(), Value::Null));
@@ -201,7 +201,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let mut d = depth;
let opts: Vec<(SampleType, &str, &str)> =
DEPTHS.iter().map(|(d, l, _)| (*d, *l, if *d == SampleType::F32 { "Floating point" } else { "Integer" })).collect();
if widgets::dropdown_with_tooltips(ui, "doc-props-depth", &mut d, &opts, 150.0)
if widgets::dropdown_with_tooltips(ui, "doc-props-depth", tl!("Depth"), &mut d, &opts, 150.0)
&& let Some((_, _, id)) = DEPTHS.iter().find(|(x, _, _)| *x == d)
{
run.push(((*id).to_string(), Value::Null));
@@ -214,7 +214,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// The canvas fill is only editable for documents without a Background layer.
ui.add_enabled_ui(!has_bg, |ui| {
let mut f = 0u8;
widgets::dropdown(ui, "doc-props-fill", &mut f, &[(0u8, tl!("Background Color"))], 124.0);
widgets::dropdown(ui, "doc-props-fill", tl!("Background"), &mut f, &[(0u8, tl!("Background Color"))], 124.0);
});
});
ui.add_space(4.0);
@@ -238,7 +238,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.add_space(8.0);
let mut unit = app.session.prefs().units_and_rulers.rulers.name().to_string();
let opts: Vec<(String, &str)> = UNITS.iter().map(|(k, l)| (k.to_string(), *l)).collect();
if widgets::dropdown(ui, "doc-props-units", &mut unit, &opts, 96.0) {
if widgets::dropdown(ui, "doc-props-units", tl!("Units"), &mut unit, &opts, 96.0) {
run.push(("prefs.set".into(), json!({"path": "unitsAndRulers.rulers", "value": unit})));
}
});
+10 -4
View File
@@ -453,13 +453,13 @@ pub fn last_rects(ctx: &egui::Context) -> Vec<(Group, Rect)> {
}
fn rects_id() -> egui::Id {
egui::Id::new("dock-group-rects")
egui::Id::unique("dock-group-rects")
}
/// The group expanded from its collapsed tab strip (see [`show`]), if any: with Preferences ▸
/// Workspace ▸ Auto-Collapse Iconic Panels on, the first click outside it collapses it again.
fn peek_id() -> egui::Id {
egui::Id::new("dock-peek")
egui::Id::unique("dock-peek")
}
fn auto_collapse_peek(app: &mut PhotocraftApp, ctx: &egui::Context) {
@@ -496,7 +496,7 @@ pub fn last_strips(ctx: &egui::Context) -> Vec<StripRects> {
}
fn strips_id() -> egui::Id {
egui::Id::new("dock-strip-rects")
egui::Id::unique("dock-strip-rects")
}
/// Draw the `shown` groups (any order; the layout decides) filling `ui`. `body` draws one
@@ -677,7 +677,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
// Splitter in the gap below this group: resizes it, pushing the neighbouring groups.
if i + 1 < rects.len() && !collapsed && rects.iter().skip(i + 1).any(|(n, _)| !app.ui.dock.is_collapsed(*n)) {
let gap = Rect::from_min_size(pos2(rect.left(), rect.bottom()), vec2(rect.width(), GAP)).expand2(vec2(0.0, 2.0));
let sresp = ui.interact(gap, ui.id().with(("dock-splitter", g.key())), Sense::drag());
let sresp = ui.interact(gap, ui.scope_id().with(("dock-splitter", g.key())), Sense::drag());
if sresp.hovered() || sresp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
ui.painter().line_segment([gap.left_center(), gap.right_center()], Stroke::new(2.0, t.accent.gamma_multiply(0.7)));
@@ -855,6 +855,12 @@ pub fn restore(app: &mut PhotocraftApp) {
/// Apply the `panels`, `dockTabs`, `dock` and `timelineOpen` parts of a saved layout
/// (a workspace or `panelLayout`). Missing or invalid dock parts are left alone; old
/// layouts without Timeline visibility restore it closed.
///
/// `craft_ui::docking::DockArea` keeps only transient drag/focus data in `ctx.data`; the
/// split/tab arrangement is the app's own `craft_ui::docking::Layout` JSON stored here. If a
/// layout saved by a different craft-ui revision no longer deserializes, it is dropped and the
/// legacy layout is rebuilt (the split/tab arrangement resets). Panel widths are a separate
/// `egui::PanelState` migration and still carry over.
pub fn apply(app: &mut PhotocraftApp, v: &Value) {
app.ui.docking_collapsed = v.get("dockingCollapsed").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
app.ui.docking_reveal = None;
+18 -1
View File
@@ -739,7 +739,7 @@ fn theme_switch_keeps_the_selected_color_group_panel() {
if *name == "Color" {
// The spectrum field's hex readout, the same in every theme (#6000).
assert!(h.query_by_label("#").is_some(), "{theme:?}: Color field missing");
assert!(h.query_by_label("Hue").is_none(), "{theme:?}: no HSB sliders");
assert!(h.query_all_by_label("Hue").next().is_none(), "{theme:?}: no HSB sliders");
}
h.state_mut().set_theme(&ctx, ThemeKind::Pro);
h.run_steps(4);
@@ -1245,6 +1245,23 @@ fn brush_settings_window_is_closed_on_restart_but_can_be_reopened() {
assert!(workspace.ui.panels.brush_settings, "switching workspaces still restores their saved layout");
}
/// The saved `docking` value is the app's own serialization of `craft_ui::docking::Layout`. A
/// value from an older/foreign docking schema that no longer deserializes is dropped (the
/// legacy layout is rebuilt); the rest of the workspace still applies, and nothing panics.
/// Panel widths still migrate separately through `panels::migrate_panel_state`, but a split/tab
/// arrangement from such a schema may reset. No 0.36 sample was available to prove the actual
/// transition.
#[test]
fn an_unreadable_docking_layout_is_dropped_and_the_rest_applies() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let mut saved = snapshot(&app);
saved["docking"] = json!({"root": "not-a-dock-node"});
saved["timelineOpen"] = json!(true);
apply(&mut app, &saved);
assert!(app.ui.docking.is_none(), "the unreadable docking value is dropped");
assert!(app.ui.timeline.open, "the rest of the layout still applies");
}
/// Layers panel options live in the preferences on their own (like Photoshop's Panel Options),
/// so a workspace layout neither saves nor restores them.
#[test]
+5 -5
View File
@@ -48,21 +48,21 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map<String, Val
egui::Grid::new("duplicate-layer").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
if let Some(name) = fields.get("name").and_then(Value::as_str) {
let mut name = name.to_string();
ui.label(tl!("Name:"));
ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0));
let name_label = ui.label(tl!("Name:"));
ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)).labelled_by(name_label.id);
fields.insert("name".into(), json!(name));
ui.end_row();
}
ui.label(egui::RichText::new(tl!("Destination")).font(crate::theme::semibold(13.0)));
ui.end_row();
ui.label(tl!("Document"));
crate::widgets::dropdown(ui, "duplicate-layer-document", &mut destination, &options, 260.0);
crate::widgets::dropdown(ui, "duplicate-layer-document", tl!("Document"), &mut destination, &options, 260.0);
ui.end_row();
// Photoshop's document-`Name` is only there when the destination is a new document.
if destination == "new" {
let mut doc_name = fields.get("documentName").and_then(Value::as_str).unwrap_or("").to_string();
ui.label(tl!("Name:"));
ui.add(egui::TextEdit::singleline(&mut doc_name).desired_width(260.0));
let name_label = ui.label(tl!("Name:"));
ui.add(egui::TextEdit::singleline(&mut doc_name).desired_width(260.0)).labelled_by(name_label.id);
fields.insert("documentName".into(), json!(doc_name));
ui.end_row();
}
+36 -6
View File
@@ -1,5 +1,5 @@
//! egui's default fonts (Ubuntu Light, Hack, Noto Emoji and emoji-icon-font: the fallback stack
//! behind Inter and JetBrains Mono, for symbols and emoji), stored deflated.
//! egui's default fonts (Ubuntu Light, Hack, egui-icons, Noto Emoji and emoji-icon-font: the
//! fallback stack behind Inter and JetBrains Mono, for symbols and emoji), stored deflated.
//!
//! egui's `default_fonts` feature embeds them uncompressed (1.4 MB), which the web build's 24 MiB
//! size gate (packaging/web/package.sh) can't afford. The app builds egui without it; build.rs
@@ -19,6 +19,7 @@ macro_rules! deflated {
}
static HACK: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("Hack-Regular")));
static EGUI_ICONS: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("egui-icons")));
static NOTO_EMOJI: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("NotoEmoji-Regular")));
static UBUNTU_LIGHT: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("Ubuntu-Light")));
static EMOJI_ICON: LazyLock<Vec<u8>> = LazyLock::new(|| inflate(deflated!("emoji-icon-font")));
@@ -46,11 +47,13 @@ pub fn definitions() -> FontDefinitions {
/// egui's default definitions, built from the deflated copies.
fn from_deflated() -> FontDefinitions {
let mut fonts = FontDefinitions::empty();
let faces: [(&str, &'static [u8], f32); 4] = [
let faces: [(&str, &'static [u8], f32); 5] = [
("Hack", HACK.as_slice(), 1.0),
("Ubuntu-Light", UBUNTU_LIGHT.as_slice(), 1.0),
// The handful of icons in `egui::special_emojis`, which no platform font has.
("egui-icons", EGUI_ICONS.as_slice(), 0.90),
// egui draws its emoji fonts a little smaller.
("NotoEmoji-Regular", NOTO_EMOJI.as_slice(), 0.81),
("Ubuntu-Light", UBUNTU_LIGHT.as_slice(), 1.0),
("emoji-icon-font", EMOJI_ICON.as_slice(), 0.90),
];
for (name, bytes, scale) in faces {
@@ -62,8 +65,9 @@ fn from_deflated() -> FontDefinitions {
fonts.font_data.insert(name.to_owned(), Arc::new(data));
}
let stack = |names: &[&str]| -> Vec<String> { names.iter().filter(|n| fonts.font_data.contains_key(**n)).map(|n| (*n).to_owned()).collect() };
let mono = stack(&["Hack", "Ubuntu-Light", "NotoEmoji-Regular", "emoji-icon-font"]);
let proportional = stack(&["Ubuntu-Light", "NotoEmoji-Regular", "emoji-icon-font"]);
// 0.37's family order: egui-icons and the monochrome emoji fonts last, as fallbacks.
let mono = stack(&["Hack", "Ubuntu-Light", "egui-icons", "NotoEmoji-Regular", "emoji-icon-font"]);
let proportional = stack(&["Ubuntu-Light", "egui-icons", "NotoEmoji-Regular", "emoji-icon-font"]);
fonts.families.insert(FontFamily::Monospace, mono);
fonts.families.insert(FontFamily::Proportional, proportional);
fonts
@@ -83,6 +87,32 @@ mod tests {
assert_eq!(definitions(), egui);
}
/// The deflated stack carries egui 0.37's `egui-icons` face and the monochrome emoji faces,
/// so `egui::special_emojis` and the UI's symbol glyphs do not render as tofu (kittest's
/// `MissingGlyphPolicy::Panic` depends on this).
#[test]
fn the_deflated_stack_draws_egui_icons() {
let ctx = egui::Context::default();
ctx.set_fonts(from_deflated());
let mut output = ctx.run_ui(Default::default(), |_| {});
// The headless test never uploads the atlas; drop its delta deliberately.
output.textures_delta.clear();
let id = egui::FontId::proportional(14.0);
let icons: String = [
egui::special_emojis::OS_LINUX,
egui::special_emojis::OS_WINDOWS,
egui::special_emojis::OS_ANDROID,
egui::special_emojis::OS_APPLE,
egui::special_emojis::GITHUB,
egui::special_emojis::GIT,
]
.into_iter()
.collect();
assert!(ctx.fonts_mut(|f| f.has_glyphs(&id, &icons)), "egui-icons covers every special emoji");
let mono = egui::FontId::monospace(14.0);
assert!(ctx.fonts_mut(|f| f.has_glyphs(&mono, &icons)), "and the monospace family too");
}
#[test]
fn corrupt_font_data_is_left_out_not_a_panic() {
assert!(inflate(b"not deflate data").is_empty());
+4 -3
View File
@@ -81,12 +81,13 @@ pub fn block_size(app: &PhotocraftApp) -> f32 {
pub fn mode_dropdown(ui: &mut egui::Ui, mode: &mut String) -> bool {
opt(ui, tl!("Mode:"));
let modes = [("brush".to_string(), tl!("Brush")), ("pencil".to_string(), tl!("Pencil")), ("block".to_string(), tl!("Block"))];
widgets::dropdown(ui, "eraser-mode", mode, &modes, 96.0)
widgets::dropdown(ui, "eraser-mode", tl!("Mode"), mode, &modes, 96.0)
}
fn opt(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!(&text)).color(t.text_dim).size(12.0));
let label = ui.label(egui::RichText::new(tl!(&text)).color(t.text_dim).size(12.0));
crate::widgets::scrub_option_label(ui, &label);
}
/// Options bar for the Magic Eraser and Background Eraser. Returns false for other tools.
@@ -123,7 +124,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
("contiguous".to_string(), tl!("Contiguous")),
("findEdges".to_string(), tl!("Find Edges")),
];
widgets::dropdown(ui, "bg-eraser-limits", &mut o.bg_limits, &limits, 110.0);
widgets::dropdown(ui, "bg-eraser-limits", tl!("Limits:"), &mut o.bg_limits, &limits, 110.0);
opt(ui, tl!("Tolerance:"));
widgets::value_field(ui, &mut o.bg_tolerance, 0.0..=100.0, "%", 62.0);
widgets::checkbox(ui, &mut o.bg_protect_fg, tl!("Protect Foreground Color"));
+13 -13
View File
@@ -553,7 +553,7 @@ fn poll_or_start_preview(
doc: Arc<Document>,
fields: &Map<String, Value>,
) -> Option<Result<PreviewPixels, String>> {
let pending_key = egui::Id::new(("export-preview-pending", doc.id.0));
let pending_key = egui::Id::unique(("export-preview-pending", doc.id.0));
let pending: Option<PendingPreview> = ui.data(|d| d.get_temp(pending_key));
if let Some(pending) = pending {
let result = pending.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?;
@@ -679,7 +679,7 @@ fn size_fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, src_w: f64, src_h:
let mut sc = passed as f32;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Scale")).color(t.text_dim));
crate::widgets::value_field(ui, &mut sc, 1.0..=1000.0, "%", 70.0);
crate::widgets::value_field(ui, &mut sc, 1.0..=1000.0, "%", 70.0).accessible_name(tl!("Scale"));
});
let sc_r = f64::from(sc).round().clamp(1.0, 1000.0);
if (sc_r - passed).abs() > 0.01
@@ -692,7 +692,7 @@ fn size_fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, src_w: f64, src_h:
let mut width = stored_w.clamp(1.0, MAX_PX) as f32;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Width")).color(t.text_dim));
crate::widgets::value_field(ui, &mut width, 1.0..=MAX_PX as f32, "px", 70.0);
crate::widgets::value_field(ui, &mut width, 1.0..=MAX_PX as f32, "px", 70.0).accessible_name(tl!("Width"));
});
let width_r = f64::from(width).round();
if (width_r - stored_w.round()).abs() > 0.01
@@ -704,7 +704,7 @@ fn size_fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, src_w: f64, src_h:
let mut height = stored_h.clamp(1.0, MAX_PX) as f32;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Height")).color(t.text_dim));
crate::widgets::value_field(ui, &mut height, 1.0..=MAX_PX as f32, "px", 70.0);
crate::widgets::value_field(ui, &mut height, 1.0..=MAX_PX as f32, "px", 70.0).accessible_name(tl!("Height"));
});
let height_r = f64::from(height).round();
if (height_r - stored_h.round()).abs() > 0.01
@@ -729,7 +729,7 @@ fn resample_field(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.label(egui::RichText::new(tl!("Resample")).color(t.text_dim));
let mut method = resample_method(f, None).unwrap_or(DEFAULT_RESAMPLE).to_string();
let opts: Vec<(String, &str)> = RESAMPLE.iter().map(|(k, label)| ((*k).to_string(), resample_label(label))).collect();
if crate::widgets::dropdown(ui, "export-resample", &mut method, &opts, 130.0) {
if crate::widgets::dropdown(ui, "export-resample", tl!("Resample"), &mut method, &opts, 130.0) {
f.insert("resample".into(), json!(method));
}
});
@@ -808,7 +808,7 @@ fn preset_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String,
for name in &names {
opts.push((name.clone(), name.as_str()));
}
if crate::widgets::dropdown(ui, "export-preset", &mut selected, &opts, 130.0) {
if crate::widgets::dropdown(ui, "export-preset", tl!("Preset"), &mut selected, &opts, 130.0) {
f.insert("__preset".into(), json!(selected));
if let Some(preset) = app.session.presets.export_presets.iter().find(|p| p.name == s(f, "__preset")).cloned() {
apply_preset(f, &preset.settings, src_w, src_h);
@@ -878,7 +878,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
ui.label(egui::RichText::new(tl!("Format")).color(t.text_dim));
let mut fmt = s_fmt(f);
let opts: Vec<(String, &str)> = FORMATS.iter().map(|(k, l)| (k.to_string(), *l)).collect();
if crate::widgets::dropdown(ui, "export-format", &mut fmt, &opts, 130.0) {
if crate::widgets::dropdown(ui, "export-format", tl!("Format"), &mut fmt, &opts, 130.0) {
set_format_defaults(f, &fmt, &app.session.prefs().export);
}
});
@@ -899,7 +899,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
let mut bits = s(f, "tgaBits");
let opts: Vec<(String, &str)> =
vec![("auto".into(), tl!("Auto")), ("24".into(), tl!("24 bits/pixel")), ("32".into(), tl!("32 bits/pixel"))];
if crate::widgets::dropdown(ui, "export-tga-bits", &mut bits, &opts, 130.0) {
if crate::widgets::dropdown(ui, "export-tga-bits", tl!("Depth"), &mut bits, &opts, 130.0) {
f.insert("tgaBits".into(), json!(bits));
}
});
@@ -913,7 +913,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
ui.label(egui::RichText::new(tl!("Metadata")).color(t.text_dim));
let mut m = s(f, "metadata");
let opts: Vec<(String, &str)> = vec![("none".into(), tl!("None")), ("exif".into(), tl!("EXIF")), ("all".into(), tl!("All"))];
if crate::widgets::dropdown(ui, "export-metadata", &mut m, &opts, 130.0) {
if crate::widgets::dropdown(ui, "export-metadata", tl!("Metadata"), &mut m, &opts, 130.0) {
f.insert("metadata".into(), json!(m));
}
});
@@ -924,7 +924,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
profile = "document".into();
}
let opts: Vec<(String, &str)> = vec![("document".into(), tl!("Document Profile")), ("srgb".into(), tl!("Convert to sRGB"))];
if crate::widgets::dropdown(ui, "export-profile", &mut profile, &opts, 130.0) {
if crate::widgets::dropdown(ui, "export-profile", tl!("Color Profile"), &mut profile, &opts, 130.0) {
f.insert("profile".into(), json!(profile));
}
});
@@ -947,7 +947,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
// Right: preview + estimated size.
ui.vertical(|ui| {
let layer = f.get("__layer").and_then(Value::as_u64);
let key = egui::Id::new(("export-preview", doc.id.0, app.session.active().map_or(0, |s| s.revision), layer));
let key = egui::Id::unique(("export-preview", doc.id.0, app.session.active().map_or(0, |s| s.revision), layer));
let sig = format!(
"{}{}{}{}{}{}{}{}{}{}{}{}{}",
s_fmt(f),
@@ -1541,7 +1541,7 @@ mod tests {
assert!((old as f64 / actual as f64 - 1.0).abs() > 0.1, "the proxy estimate was off: {old} vs {actual}");
let ctx = egui::Context::default();
let key = egui::Id::new("exact-estimate-test");
let key = egui::Id::unique("exact-estimate-test");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
let size = loop {
let mut size = None;
@@ -1680,7 +1680,7 @@ mod tests {
assert!(background_preview_needed(&doc));
let fields = Map::from_iter([("format".into(), json!("png")), ("scale".into(), json!(100))]);
let ctx = egui::Context::default();
let key = egui::Id::new("layered-preview-test");
let key = egui::Id::unique("layered-preview-test");
let mut result = None;
let mut output = ctx.run_ui(Default::default(), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
+2 -2
View File
@@ -42,9 +42,9 @@ fn opt(ui: &mut egui::Ui, text: &str) {
pub fn options(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let o = &mut app.ui.tool_options;
opt(ui, tl!("Sample Size:"));
widgets::dropdown(ui, "eyedropper-size", &mut o.eyedropper_size, &size_options(), 130.0);
widgets::dropdown(ui, "eyedropper-size", tl!("Size"), &mut o.eyedropper_size, &size_options(), 130.0);
opt(ui, tl!("Sample:"));
widgets::dropdown(ui, "eyedropper-sample", &mut o.eyedropper_sample, &layer_options(), 190.0);
widgets::dropdown(ui, "eyedropper-sample", tl!("Source"), &mut o.eyedropper_sample, &layer_options(), 190.0);
widgets::checkbox(ui, &mut o.eyedropper_ring, tl!("Show Sampling Ring"));
widgets::vline(ui, 22.0);
let t = Tokens::get(ui.ctx());
+3 -3
View File
@@ -12,15 +12,15 @@
use egui::{Context, Id, Key, Modifiers};
fn list_id() -> Id {
Id::new("pc-field-tab-order")
Id::unique("pc-field-tab-order")
}
fn collecting_id() -> Id {
Id::new("pc-field-tab-collecting")
Id::unique("pc-field-tab-collecting")
}
fn pending_id() -> Id {
Id::new("pc-field-tab-pending")
Id::unique("pc-field-tab-pending")
}
/// Give `id` the focus from the next frame on. Focusing a widget after it was laid out would
+51 -16
View File
@@ -30,15 +30,15 @@ struct Gesture {
}
fn gesture_id() -> Id {
Id::new("pc-field-wheel-gesture")
Id::unique("pc-field-wheel-gesture")
}
fn focused_id() -> Id {
Id::new("pc-field-wheel-focused")
Id::unique("pc-field-wheel-focused")
}
fn taken_id() -> Id {
Id::new("pc-field-wheel-taken")
Id::unique("pc-field-wheel-taken")
}
/// A wheel event a field takes: plain or Shift, not the view's Alt or Ctrl/Cmd zoom, nor a plain
@@ -167,11 +167,12 @@ mod tests {
|ui, s: &mut State| {
super::begin_frame(ui.ctx());
let out = egui::ScrollArea::both().max_height(100.0).max_width(200.0).show(ui, |ui| {
ui.add_enabled_ui(s.2, |ui| crate::widgets::value_field(ui, &mut s.0, 0.0..=1000.0, "px", 90.0));
ui.add_enabled_ui(s.2, |ui| crate::widgets::value_field(ui, &mut s.0, 0.0..=1000.0, "px", 90.0).accessible_name("Size"));
ui.allocate_space(vec2(1000.0, 1000.0));
});
s.1 = out.state.offset.y;
s.3 = out.state.offset.x;
let offset = out.state.clamped_offset();
s.1 = offset.y;
s.3 = offset.x;
},
(value, 0.0, true, 0.0),
);
@@ -187,7 +188,13 @@ mod tests {
fn wheel(h: &mut Harness<'static, State>, at: Pos2, lines: f32, modifiers: Modifiers) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: egui::TouchPhase::Move, modifiers });
h.event(egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, lines),
phase: egui::TouchPhase::Move,
modifiers,
});
h.run_steps(4);
}
@@ -225,8 +232,13 @@ mod tests {
let below = field(&h).center() + vec2(0.0, 40.0);
h.hover_at(below);
h.run_steps(1);
let down =
|| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE };
let down = || egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
};
h.event(down());
h.run_steps(1);
// Still the same gesture as the pointer reaches a field.
@@ -251,7 +263,13 @@ mod tests {
let at = field(&h).center();
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(1.0, 0.0), phase: egui::TouchPhase::Move, modifiers: m });
h.event(egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: vec2(1.0, 0.0),
phase: egui::TouchPhase::Move,
modifiers: m,
});
h.run_steps(4);
assert_eq!(h.state().0, want, "focus {focus}, {m:?}");
}
@@ -265,7 +283,13 @@ mod tests {
let mut h = harness(100.0);
h.hover_at(field(&h).center());
h.run_steps(1);
let ev = |phase, y| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, y), phase, modifiers: Modifiers::NONE };
let ev = |phase, y| egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, y),
phase,
modifiers: Modifiers::NONE,
};
h.event(ev(egui::TouchPhase::Start, 0.0));
h.run_steps(1);
h.event(ev(egui::TouchPhase::Move, 2.0));
@@ -281,7 +305,13 @@ mod tests {
let mut h = harness(100.0);
h.hover_at(field(&h).center());
h.run_steps(1);
let ev = |phase, x| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(x, 0.0), phase, modifiers: Modifiers::NONE };
let ev = |phase, x| egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: vec2(x, 0.0),
phase,
modifiers: Modifiers::NONE,
};
h.event(ev(egui::TouchPhase::Start, 0.0));
h.run_steps(1);
h.event(ev(egui::TouchPhase::Move, -2.0));
@@ -298,7 +328,7 @@ mod tests {
let mut h = harness_stepping(100.0, 1.0 / 30.0);
h.hover_at(field(&h).center());
h.run_steps(1);
let ev = |phase, unit, d| egui::Event::MouseWheel { unit, delta: d, phase, modifiers: Modifiers::NONE };
let ev = |phase, unit, d| egui::Event::MouseWheel { source: egui::MouseWheelSource::Unknown, unit, delta: d, phase, modifiers: Modifiers::NONE };
h.event(ev(egui::TouchPhase::Start, egui::MouseWheelUnit::Point, vec2(0.0, 0.0)));
h.run_steps(1);
// A short swipe, so the field stays in view.
@@ -321,8 +351,13 @@ mod tests {
h.hover_at(field(&h).center());
h.run_steps(1);
// Up, so the panel (already at the top) doesn't move the field.
let up =
|| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE };
let up = || egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, 1.0),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
};
h.event(up());
h.run_steps(1);
assert_eq!(h.state().0, 101.0);
@@ -369,7 +404,7 @@ mod tests {
use egui::MouseWheelUnit::{Line, Point};
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| {
let id = egui::Id::new("acc");
let id = egui::Id::unique("acc");
for d in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
for unit in [Line, Point] {
assert_eq!(crate::widgets::count_notches(ui, id, &[(unit, vec2(0.0, d), false)], 10.0), 0.0, "{d} {unit:?}");
+14 -2
View File
@@ -30,7 +30,13 @@ fn field<'a>(h: &'a Harness<'_, PhotocraftApp>, value: &str) -> egui_kittest::No
fn wheel(h: &mut Harness<'_, PhotocraftApp>, at: egui::Pos2, lines: f32, modifiers: egui::Modifiers) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: egui::TouchPhase::Move, modifiers });
h.event(egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, lines),
phase: egui::TouchPhase::Move,
modifiers,
});
h.run_steps(6);
}
@@ -84,7 +90,13 @@ fn canvas_pan(focus: bool, delta: egui::Vec2, modifiers: egui::Modifiers) -> (f3
h.hover_at(canvas);
h.run_steps(1);
let before = view(&h);
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta, phase: egui::TouchPhase::Move, modifiers });
h.event(egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Line,
delta,
phase: egui::TouchPhase::Move,
modifiers,
});
h.run_steps(40);
assert!(h.query_all_by_role(Role::SpinButton).any(|n| n.value().as_deref() == Some("10")), "focus {focus}: the field wasn't stepped");
let after = view(&h);
+33 -26
View File
@@ -290,7 +290,7 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<
// A bounded proxy; navigation never recomposites or re-encodes it.
let base = p.cloned().unwrap_or_else(|| json!({}));
let size_sig = format!("{}|{}|{}|{}|{}", base["percent"], base["width"], base["height"], base["resample"], base["convertToSrgb"]);
let wkey = egui::Id::new(("web-proxy", doc.id.0, pane));
let wkey = egui::Id::unique(("web-proxy", doc.id.0, pane));
let proxy_sig = format!("{rev}|{frame:?}|{size_sig}");
let cached_proxy: Option<(String, WebProxy)> = ui.data(|d| d.get_temp(wkey));
let proxy = cached_proxy.filter(|(sig, _)| sig == &proxy_sig).map(|(_, proxy)| proxy);
@@ -326,7 +326,7 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<
}
};
let sig = format!("{}|{rev}|{frame:?}|{size_sig}|{}", doc.id.0, p.map(|v| v.to_string()).unwrap_or_else(|| "original".into()));
let key = egui::Id::new(("web-pane", doc.id.0, pane));
let key = egui::Id::unique(("web-pane", doc.id.0, pane));
let cached: Option<WebCache> = ui.data(|d| d.get_temp(key));
let (tex, info) = match cached.filter(|c| c.0 == sig) {
Some((_, tex, info)) => (tex, info),
@@ -395,10 +395,10 @@ fn printer_options(list: Option<&Value>, current: &str) -> Vec<(String, String)>
opts
}
fn dropdown_str(ui: &mut egui::Ui, id: &str, f: &mut Map<String, Value>, key: &str, opts: &[(&str, &str)], w: f32) {
fn dropdown_str(ui: &mut egui::Ui, id: &str, name: &str, f: &mut Map<String, Value>, key: &str, opts: &[(&str, &str)], w: f32) {
let mut v = s(f, key, opts.first().map_or("", |o| o.0));
let o: Vec<(String, &str)> = opts.iter().map(|(k, l)| (k.to_string(), *l)).collect();
if crate::widgets::dropdown(ui, id, &mut v, &o, w) {
if crate::widgets::dropdown(ui, id, name, &mut v, &o, w) {
f.insert(key.into(), json!(v));
}
}
@@ -414,7 +414,7 @@ fn number(ui: &mut egui::Ui, f: &mut Map<String, Value>, key: &str, label: &str,
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim));
let mut v = n(f, key, d) as f32;
crate::widgets::value_field(ui, &mut v, range, unit, 64.0);
crate::widgets::value_field(ui, &mut v, range, unit, 64.0).accessible_name(tl!(&label));
f.insert(key.into(), json!(v.round()));
});
}
@@ -542,14 +542,14 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
let mut preset = s(f, "__preset", "");
let mut opts: Vec<(String, &str)> = vec![("".into(), "[Unnamed]")];
opts.extend(web_cmds::PRESETS.iter().map(|p| (p.to_string(), *p)));
if crate::widgets::dropdown(ui, "web-preset", &mut preset, &opts, 230.0) && !preset.is_empty() {
if crate::widgets::dropdown(ui, "web-preset", tl!("Preset"), &mut preset, &opts, 230.0) && !preset.is_empty() {
if let Some(Value::Object(m)) = web_cmds::preset_settings(&preset).map(|st| st.to_params()) {
f.extend(m);
}
f.insert("__preset".into(), json!(preset));
}
ui.add_space(4.0);
dropdown_str(ui, "web-format", f, "format", &WEB_FORMATS, 230.0);
dropdown_str(ui, "web-format", tl!("Format"), f, "format", &WEB_FORMATS, 230.0);
ui.add_space(4.0);
let fmt = s(f, "format", "jpeg");
match fmt.as_str() {
@@ -557,6 +557,7 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
dropdown_str(
ui,
"web-palette",
tl!("Color Table"),
f,
"palette",
&[
@@ -572,6 +573,7 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
dropdown_str(
ui,
"web-dither",
tl!("Dither"),
f,
"dither",
&[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern")), ("noise", tl!("Noise"))],
@@ -592,6 +594,7 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
dropdown_str(
ui,
"web-dither",
tl!("Dither"),
f,
"dither",
&[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern"))],
@@ -605,9 +608,9 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
}
if fmt != "png24" || !b(f, "transparency", true) {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim));
let matte_label = ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim));
let mut m = s(f, "matte", "#ffffff");
if ui.add(egui::TextEdit::singleline(&mut m).desired_width(80.0)).changed() {
if ui.add(egui::TextEdit::singleline(&mut m).desired_width(80.0)).labelled_by(matte_label.id).changed() {
f.insert("matte".into(), json!(m));
}
});
@@ -619,6 +622,7 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
dropdown_str(
ui,
"web-meta",
tl!("Metadata"),
f,
"metadata",
&[
@@ -641,7 +645,7 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
ui.add_space(6.0);
ui.label(egui::RichText::new(crate::i18n::trn(crate::i18n::current(), nslices as u64, "{n} slice", "{n} slices")).color(t.text_dim));
check(ui, f, "html", tl!("Save HTML and Images"), true);
dropdown_str(ui, "web-slices", f, "slices", &[("all", tl!("All Slices")), ("user", tl!("All User Slices"))], 150.0);
dropdown_str(ui, "web-slices", tl!("Slices"), f, "slices", &[("all", tl!("All Slices")), ("user", tl!("All User Slices"))], 150.0);
}
});
});
@@ -656,7 +660,7 @@ fn size_field(ui: &mut egui::Ui, label: &str, shown: f64, range: std::ops::Range
let passed = (shown as f32).clamp(*range.start(), *range.end());
let mut v = passed;
ui.label(egui::RichText::new(label).color(t.text_dim));
crate::widgets::value_field_prec(ui, &mut v, range, unit, 90.0, decimals);
crate::widgets::value_field_prec(ui, &mut v, range, unit, 90.0, decimals).accessible_name(tl!(&label));
ui.end_row();
(v != passed).then_some(f64::from(v))
}
@@ -714,6 +718,7 @@ fn web_size(ui: &mut egui::Ui, f: &mut Map<String, Value>, doc: &photocraft_doc:
dropdown_str(
ui,
"web-resample",
tl!("Resample"),
f,
"resample",
&[
@@ -943,7 +948,7 @@ fn length_field_range(ui: &mut egui::Ui, label: &str, pt: &mut f64, units: &str,
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).color(t.text_dim));
let before = v;
crate::widgets::value_field_prec(ui, &mut v, range, units, 70.0, decimals);
crate::widgets::value_field_prec(ui, &mut v, range, units, 70.0, decimals).accessible_name(tl!(&label));
if (v - before).abs() > 1e-4 && v.is_finite() {
*pt = f64::from(v) * k;
changed = true;
@@ -971,7 +976,7 @@ fn print_paper_controls(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.horizontal(|ui| {
let mut papers: Vec<_> = photocraft_engine::print_cmds::PAPERS.iter().map(|p| (p.0.to_string(), p.0)).collect();
papers.push(("custom".to_string(), tl!("Custom")));
if crate::widgets::dropdown(ui, "print-paper", &mut selected, &papers, 110.0) {
if crate::widgets::dropdown(ui, "print-paper", tl!("Paper"), &mut selected, &papers, 110.0) {
if selected == "custom" {
let (w, h) = print_paper_size(f.get("__customPaper").or(previous.as_ref()));
f.insert("paper".into(), json!([w, h]));
@@ -982,7 +987,7 @@ fn print_paper_controls(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
f.insert("paper".into(), json!(selected));
}
}
dropdown_str(ui, "print-orient", f, "orientation", &[("portrait", tl!("Portrait")), ("landscape", tl!("Landscape"))], 110.0);
dropdown_str(ui, "print-orient", tl!("Orientation"), f, "orientation", &[("portrait", tl!("Portrait")), ("landscape", tl!("Landscape"))], 110.0);
});
if f.get("paper").is_some_and(Value::is_array) {
let (mut w, mut h) = print_paper_size(f.get("paper"));
@@ -1091,10 +1096,10 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
ui.painter().rect_filled(page, 0.0, egui::Color32::WHITE);
ui.painter().rect_stroke(page, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside);
let mut img = print_preview_image(l, page, k);
let drag_id = ui.id().with(("print-position", id));
let drag_id = ui.scope_id().with(("print-position", id));
let visible = img.intersect(page).intersect(ui.clip_rect());
let response = ui.interact(visible, drag_id, egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, tl!("Print preview image")));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, tl!("Print preview image")));
// Keep a fixed layout and press origin for the whole gesture: rounding and
// the transition from Center must never accumulate or move the grab point.
if response.drag_started_by(egui::PointerButton::Primary)
@@ -1117,7 +1122,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
ui.data_mut(|d| d.remove::<(photocraft_engine::print_cmds::PrintLayout, egui::Pos2, f32)>(drag_id));
}
let (x, y, w, h) = l.rect;
let key = egui::Id::new(("print-thumb", doc.id.0, app.session.active().map_or(0, |d| d.revision)));
let key = egui::Id::unique(("print-thumb", doc.id.0, app.session.active().map_or(0, |d| d.revision)));
let tex: Option<Arc<egui::TextureHandle>> = ui.data(|d| d.get_temp(key));
let tex = tex.unwrap_or_else(|| {
let th = photocraft_compose::thumbnail(&doc, 400);
@@ -1181,7 +1186,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
ui.label(egui::RichText::new(tl!("Printer")).color(t.text_dim));
let before = s(f, "printer", "");
let opts: Vec<(&str, &str)> = printers.iter().map(|n| (n.as_str(), n.as_str())).collect();
dropdown_str(ui, "print-printer", f, "printer", &opts, 220.0);
dropdown_str(ui, "print-printer", tl!("Printer"), f, "printer", &opts, 220.0);
if s(f, "printer", "") != before {
f.remove("printerSettings");
driver_page(f);
@@ -1230,7 +1235,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
let mut pr = s(f, "printer", "");
let opts = printer_options(f.get("__systemPrinters"), &pr);
let o: Vec<(String, &str)> = opts.iter().map(|(k, l)| (k.clone(), l.as_str())).collect();
if crate::widgets::dropdown(ui, "print-printer", &mut pr, &o, 240.0) {
if crate::widgets::dropdown(ui, "print-printer", tl!("Printer"), &mut pr, &o, 240.0) {
f.insert("printer".into(), json!(pr));
}
});
@@ -1241,6 +1246,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
dropdown_str(
ui,
"print-color",
tl!("Color Management"),
f,
"colorHandling",
&[
@@ -1271,11 +1277,12 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
if !profiles.iter().any(|(v, _)| *v == cur) {
profiles.push((cur.as_str(), stem.as_str()));
}
dropdown_str(ui, "print-profile", f, "printerProfile", &profiles, 240.0);
dropdown_str(ui, "print-profile", tl!("Profile"), f, "printerProfile", &profiles, 240.0);
ui.horizontal(|ui| {
dropdown_str(
ui,
"print-intent",
tl!("Mode"),
f,
"intent",
&[
@@ -1295,7 +1302,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
}
}
head(ui, tl!("Position and Size"));
dropdown_str(ui, "print-units", f, "units", &[("cm", tl!("Centimeters")), ("mm", tl!("Millimeters")), ("in", tl!("Inches"))], 130.0);
dropdown_str(ui, "print-units", tl!("Units"), f, "units", &[("cm", tl!("Centimeters")), ("mm", tl!("Millimeters")), ("in", tl!("Inches"))], 130.0);
let units = s(f, "units", "cm");
check(ui, f, "center", tl!("Center"), true);
let centered = b(f, "center", true);
@@ -1327,7 +1334,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Scale")).color(t.text_dim));
let mut v = n(f, "scale", 100.0) as f32;
crate::widgets::value_field(ui, &mut v, 1.0..=1000.0, "%", 64.0);
crate::widgets::value_field(ui, &mut v, 1.0..=1000.0, "%", 64.0).accessible_name(tl!("Scale"));
f.insert("scale".into(), json!((f64::from(v) * 100.0).round() / 100.0));
});
}
@@ -1750,7 +1757,7 @@ mod tests {
)
};
let before = pane(&h);
let rects: Vec<egui::Rect> = h.ctx.data(|m| m.get_temp(egui::Id::new("pc-dialog-rects"))).unwrap();
let rects: Vec<egui::Rect> = h.ctx.data(|m| m.get_temp(egui::Id::unique("pc-dialog-rects"))).unwrap();
let corner = rects[0].max - egui::vec2(14.0, 14.0);
h.event(egui::Event::PointerMoved(corner));
h.step();
@@ -2115,9 +2122,9 @@ mod web_playback_tests {
h.state_mut().1.insert("__web_frame".into(), json!(frame));
h.run_steps(2);
for pane in 0usize..4 {
let proxy: (String, WebProxy) = h.ctx.data(|d| d.get_temp(egui::Id::new(("web-proxy", doc_id, pane)))).unwrap();
let proxy: (String, WebProxy) = h.ctx.data(|d| d.get_temp(egui::Id::unique(("web-proxy", doc_id, pane)))).unwrap();
assert!(proxy.1.0[0][frame] > 0.99, "pane {pane} must show frame {frame}");
let cache: WebCache = h.ctx.data(|d| d.get_temp(egui::Id::new(("web-pane", doc_id, pane)))).unwrap();
let cache: WebCache = h.ctx.data(|d| d.get_temp(egui::Id::unique(("web-pane", doc_id, pane)))).unwrap();
assert!(cache.0.contains(&format!("Some({frame})")));
}
}
@@ -2137,7 +2144,7 @@ mod web_playback_tests {
h.state_mut().1.insert("colorTable".into(), expected_palette.clone());
h.run_steps(2);
assert_eq!(h.state().1["colorTable"], expected_palette);
let key = egui::Id::new(("web-pane", doc_id, 1usize)).with("indexed-source");
let key = egui::Id::unique(("web-pane", doc_id, 1usize)).with("indexed-source");
let encoded = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(String, Arc<Optimized>)>(key)).unwrap().1;
let first = encoded(&h.ctx);
h.state_mut().1.insert("colorTableEdits".into(), json!({"0":"#ffff00"}));
+2 -2
View File
@@ -219,7 +219,7 @@ pub fn controls(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
nav.zoom = Some((nav.zoom.unwrap_or(effective) * 1.25).min(32.0));
}
let mut percent = nav.zoom.unwrap_or(effective) * 100.0;
if ui.add(egui::DragValue::new(&mut percent).range(0.01..=3200.0).suffix("%").speed(10.0)).changed() {
if ui.add(egui::DragValue::new(&mut percent).range(0.01..=3200.0).suffix("%").speed(10.0)).accessible_name(tl!("Zoom")).changed() {
nav.zoom = Some(percent / 100.0);
}
if ui.small_button(tl!("100%")).clicked() {
@@ -292,7 +292,7 @@ mod tests {
#[test]
fn palette_picker_reuses_indices_and_updates_preview_pixels() {
let key = egui::Id::new("test-web-preview");
let key = egui::Id::unique("test-web-preview");
let p = json!({"format": "gif", "colorTable": ["#ff0000", "#0000ff"], "dither": "none"});
let mut h = egui_kittest::Harness::builder().build_ui_state(
move |ui, state: &mut (Value, Vec<u8>)| {
+1 -1
View File
@@ -155,7 +155,7 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>)
ui.horizontal(|ui| {
label(ui, tl!("Contents:"));
let opts: Vec<(String, &str)> = CONTENTS.iter().map(|(k, l)| (k.to_string(), *l)).collect();
if crate::widgets::dropdown(ui, "fill-contents", &mut contents, &opts, 170.0) {
if crate::widgets::dropdown(ui, "fill-contents", tl!("Contents:"), &mut contents, &opts, 170.0) {
f.insert("contents".into(), json!(contents));
// Choosing Color… opens the Color Picker, as in Photoshop.
if contents == "color" {
+9 -8
View File
@@ -829,9 +829,10 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
// Keep one numeric field for direct entry and one log-scaled slider for
// wide ranges. slider_row would add a second, linear slider (#146).
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
let name = label(&p.key);
ui.label(egui::RichText::new(&name).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0);
crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0).accessible_name(name);
});
});
let mut lv = v.clamp(min, max).ln();
@@ -891,7 +892,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
if lists_installed && !opts.iter().any(|(v, _)| *v == cur) {
opts.push((cur.clone(), stem.as_str()));
}
crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &mut cur, &opts, 170.0);
crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &label(&p.key), &mut cur, &opts, 170.0);
if let Some(i) = cur.strip_prefix("channel:").filter(|_| depth).and_then(|i| i.parse::<usize>().ok()) {
f.insert("depthChannel".into(), json!(i));
cur = "channel".into();
@@ -909,8 +910,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
Kind::Text => {
let mut v = f.get(&p.key).and_then(Value::as_str).unwrap_or_default().to_string();
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
ui.add(egui::TextEdit::singleline(&mut v).desired_width(200.0));
let lp = ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
ui.add(egui::TextEdit::singleline(&mut v).desired_width(200.0)).labelled_by(lp.id);
// Install Plug-in…: pick the .wasm file instead of typing its path (#7531).
if cmd == "plugin.install" && p.key == "path" && crate::widgets::secondary_button(ui, tl!("Browse…"), 0.0).clicked() {
f.insert(crate::dialogs::BROWSE_FIELD.into(), json!(p.key));
@@ -931,7 +932,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
opts.extend(names.iter().enumerate().map(|(i, n)| (i.to_string(), n.as_str())));
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &mut sel, &opts, 170.0);
crate::widgets::dropdown(ui, &format!("flt-{cmd}-{}", p.key), &label(&p.key), &mut sel, &opts, 170.0);
});
match sel.parse::<i64>() {
Ok(i) if i >= 0 => f.insert(p.key, json!(i)),
@@ -946,7 +947,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
egui::Grid::new(format!("flt-grid-{cmd}-{}", p.key)).spacing([4.0, 4.0]).show(ui, |ui| {
for (i, v) in vals.iter_mut().enumerate() {
crate::widgets::value_field(ui, v, -999.0..=999.0, "", 44.0);
crate::widgets::value_field(ui, v, -999.0..=999.0, "", 44.0).accessible_name(format!("{} {}", label(&p.key), i + 1));
if (i + 1) % side == 0 {
ui.end_row();
}
@@ -991,7 +992,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let mut v = f.get(&p.key).and_then(Value::as_f64).unwrap_or(default as f64) as f32;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
crate::widgets::value_field(ui, &mut v, -30000.0..=30000.0, "", 80.0);
crate::widgets::value_field(ui, &mut v, -30000.0..=30000.0, "", 80.0).accessible_name(label(&p.key));
// A seed gets a fresh random value in one click (#5710): Clouds, Add Noise,
// Fibers… then render a new pattern, as each run of Photoshop's does.
if p.key == "seed" && ui.button(tl!("Random")).clicked() {
@@ -39,7 +39,7 @@ pub(super) fn row(ui: &mut Ui, fields: &mut Map<String, Value>) {
});
// Paint after both sliders so their edits move the marker in this very frame.
paint(ui, rect, plot, center, amount, response.has_focus());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), tl!("Center")));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), tl!("Center")));
response.on_hover_cursor(egui::CursorIcon::Crosshair);
});
for (key, value) in [("centerX", center.x), ("centerY", center.y)] {
+2 -2
View File
@@ -61,7 +61,7 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu
});
let t = Tokens::get(ui.ctx());
let cache_id = egui::Id::new(("filter-preview-ui", dialog));
let cache_id = egui::Id::unique(("filter-preview-ui", dialog));
let mut cache: Cache = ui.ctx().data_mut(|d| std::mem::take(d.get_temp_mut_or_default::<Cache>(cache_id)));
// Channel visibility and the active mask can change without a document revision. Both the
// original (press-and-hold) and filtered textures must follow the canvas's current view.
@@ -364,7 +364,7 @@ mod tests {
let id = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap();
let mut fields = app.ui.dialogs.last().unwrap().fields.clone();
let ctx = egui::Context::default();
let cache_id = egui::Id::new(("filter-preview-ui", id));
let cache_id = egui::Id::unique(("filter-preview-ui", id));
ctx.run_ui(Default::default(), |ui| window(&app, id, ui, &mut fields)).textures_delta.clear();
let before = ctx.data(|d| d.get_temp::<Cache>(cache_id).unwrap().original.unwrap().tex.id());
app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(1);

Some files were not shown because too many files have changed in this diff Show More