From cfb061c46a15c35a4661613d7ffe2f3a73c144a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:10:08 +0000 Subject: [PATCH 1/4] ci(deps): bump the actions group across 1 directory with 3 updates Updates the requirements on [actions/checkout](https://github.com/actions/checkout), [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) and [actions/upload-artifact](https://github.com/actions/upload-artifact) to permit the latest version. Updates `actions/checkout` from 4 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Commits](https://github.com/actions/checkout/compare/v4...v7) Updates `dtolnay/rust-toolchain` to b725eda525362e7cf34af9a7243cac1e3f01a7ec - [Release notes](https://github.com/dtolnay/rust-toolchain/releases) - [Commits](https://github.com/dtolnay/rust-toolchain/commits/b725eda525362e7cf34af9a7243cac1e3f01a7ec) Updates `actions/upload-artifact` from 4 to 7 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: dtolnay/rust-toolchain dependency-version: b725eda525362e7cf34af9a7243cac1e3f01a7ec dependency-type: direct:production dependency-group: actions - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/perf-nightly.yml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d729e8b553..c80ddce5f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -384,7 +384,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 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dca0dacfbb..e93efc1018 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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 diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml index 4b09278804..64592d9cf0 100644 --- a/.github/workflows/perf-nightly.yml +++ b/.github/workflows/perf-nightly.yml @@ -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 From 938542c78b5cdd6600ef70c3eb72a536a5d6c797 Mon Sep 17 00:00:00 2001 From: Erica Franco Date: Sun, 25 Oct 2026 10:32:10 -0600 Subject: [PATCH 2/4] Security: pin and verify the Windows signing client (#2936) (#3938) sign.ps1 downloaded Microsoft.Trusted.Signing.Client from the unversioned NuGet URL and loaded its dlib into signtool, next to the Azure signing secrets, with no version or integrity check. Download a pinned version (1.0.95, which the unversioned URL serves today) and refuse it unless its SHA-256 matches, so upgrading the client is a deliberate change in the repository. The release docs say where the pin lives. --- book/src/development/release-security.md | 1 + docs/releasing.md | 4 +++- packaging/windows/sign.ps1 | 17 ++++++++++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/book/src/development/release-security.md b/book/src/development/release-security.md index 742ab11095..99a95f1f82 100644 --- a/book/src/development/release-security.md +++ b/book/src/development/release-security.md @@ -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. diff --git a/docs/releasing.md b/docs/releasing.md index 81e24ba8ef..502e46b3ac 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -180,7 +180,9 @@ goes in `CARGO_TARGET__RUSTFLAGS`, so host build scripts aren't affected 1. a `.pfx` file (`WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD`), timestamped by DigiCert, or 2. Azure Trusted Signing (`AZURE_*`): the script downloads the - `Microsoft.Trusted.Signing.Client` dlib and timestamps with Microsoft's server. + `Microsoft.Trusted.Signing.Client` dlib and timestamps with Microsoft's server. The package + is pinned to a version and SHA-256 at the top of `sign.ps1` and refused on a mismatch, so an + upgrade is a deliberate change to both. The script is the single place to change when the Windows signing setup changes. diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index 5dcefceb03..6064a9409b 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -23,6 +23,16 @@ param( ) $ErrorActionPreference = 'Stop' +# Azure Trusted Signing loads this NuGet package's dlib into signtool, beside the signing secrets, +# so the package is pinned to a reviewed version and SHA-256 and refused on any mismatch (#2936). +# Upgrading is a deliberate change to both values: download +# https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/ and record +# (Get-FileHash -Algorithm SHA256 ).Hash. +$TrustedSigningClient = @{ + Version = '1.0.95' + Sha256 = '3BFCF1E0A3CB42AF1692F0A8ED45C15DE070C2DE86F28A59B2795D904D8A920F' +} + function Write-Warn([string] $Message) { if ($env:GITHUB_ACTIONS) { Write-Output "::warning::$Message" } else { Write-Warning $Message } } @@ -74,7 +84,12 @@ try { # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. $pkg = Join-Path $tmp 'trusted-signing.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg + $version = $TrustedSigningClient.Version + Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/$version" -OutFile $pkg + $sha256 = (Get-FileHash -Algorithm SHA256 -Path $pkg).Hash + if ($sha256 -ne $TrustedSigningClient.Sha256) { + throw "Microsoft.Trusted.Signing.Client $version has SHA-256 $sha256, expected $($TrustedSigningClient.Sha256): refusing to load it" + } Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 From 5c9192bbe2031aecc6023175b24d653795e30872 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 13:04:35 -0600 Subject: [PATCH 3/4] Migrate PhotoCraft to egui 0.37 (#9363) * egui037: name each control for what it sets, and order the eager fonts for the UI CodeRabbit review follow-ups on the migration: the Artboard X/Y fields, the CAF dropdowns, the channel Solidity field, the GPU backend/adapter dropdowns, the corner radius fields and the canvas extension colour all had an accessible name that named another control (or was shared by four). The eager craft-fonts registration now puts the UI Japanese face first, as the lazy loader does, so Japanese UI is not drawn in the manifest's Mincho face and shared Han characters do not take Japanese forms. Generated with Codebuff Co-Authored-By: Codebuff * egui037: rustfmt the eager craft-font order --------- Co-authored-by: Dylan M. Taylor Co-authored-by: Codebuff --- .github/workflows/ci.yml | 15 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- ATTRIBUTION.md | 1 + Cargo.lock | 204 +- Cargo.toml | 34 +- NOTICE | 17 + apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft-web/src/web.rs | 11 +- apps/photocraft/Cargo.toml | 3 +- apps/photocraft/src/main.rs | 97 +- apps/photocraft/src/ui_state.rs | 334 ++- apps/photocraft/tests/fixtures/README.md | 7 + apps/photocraft/tests/fixtures/ui-0.14.ron | 1 + apps/photocraft/tests/fixtures/ui-0.15.ron | 1 + apps/photocraft/tests/fixtures/ui-0.37.ron | 1 + crates/engine/src/jobs.rs | 2 + crates/engine/src/lib.rs | 25 + crates/engine/src/smart_cmds.rs | 2 + crates/engine/src/tests.rs | 22 + crates/ui-egui/Cargo.toml | 9 +- crates/ui-egui/build.rs | 3 + crates/ui-egui/examples/color_picker_demo.rs | 2 +- crates/ui-egui/src/actions.rs | 28 +- crates/ui-egui/src/adjust_dialog.rs | 48 +- crates/ui-egui/src/adjust_editors.rs | 31 +- crates/ui-egui/src/adjust_editors_tests.rs | 34 +- crates/ui-egui/src/adjust_ui.rs | 10 +- crates/ui-egui/src/analysis_ui.rs | 25 +- crates/ui-egui/src/apply_color.rs | 2 +- crates/ui-egui/src/artboard_tool.rs | 6 +- crates/ui-egui/src/artboard_ui.rs | 18 +- crates/ui-egui/src/background_redraw.rs | 32 + crates/ui-egui/src/background_redraw_tests.rs | 6 +- crates/ui-egui/src/bitmap_dialog.rs | 18 +- .../ui-egui/src/blend_dropdown_wheel_tests.rs | 1 + crates/ui-egui/src/brand.rs | 4 +- crates/ui-egui/src/brush_cursor.rs | 2 +- crates/ui-egui/src/brush_panel.rs | 20 +- crates/ui-egui/src/brush_panel_tests.rs | 20 +- crates/ui-egui/src/brush_picker.rs | 12 +- crates/ui-egui/src/brush_preset_dialog.rs | 4 +- crates/ui-egui/src/brush_preview.rs | 2 +- crates/ui-egui/src/brush_resize.rs | 4 +- crates/ui-egui/src/brush_sections.rs | 40 +- crates/ui-egui/src/brushes_tab.rs | 20 +- crates/ui-egui/src/brushes_tab_tests.rs | 4 +- crates/ui-egui/src/caf_ui.rs | 29 +- crates/ui-egui/src/camera_raw_preview_ui.rs | 10 +- crates/ui-egui/src/camera_raw_scope_ui.rs | 8 +- crates/ui-egui/src/camera_raw_ui.rs | 9 +- crates/ui-egui/src/canvas.rs | 116 +- crates/ui-egui/src/canvas_menu_tests.rs | 4 +- crates/ui-egui/src/canvas_popup.rs | 6 +- crates/ui-egui/src/canvas_tool_menu.rs | 2 +- crates/ui-egui/src/cas_transform.rs | 5 +- crates/ui-egui/src/channel_options_ui.rs | 13 +- crates/ui-egui/src/channels_panel.rs | 18 +- crates/ui-egui/src/chrome_ui.rs | 5 +- crates/ui-egui/src/cjk_fonts.rs | 28 +- crates/ui-egui/src/clip_line_ui.rs | 4 +- .../ui-egui/src/clipping_indicator_tests.rs | 41 +- crates/ui-egui/src/color_panel_ui.rs | 30 +- crates/ui-egui/src/color_picker_ui.rs | 32 +- crates/ui-egui/src/color_range_ui.rs | 31 +- crates/ui-egui/src/color_tab_theme_tests.rs | 4 +- crates/ui-egui/src/comps_ui.rs | 6 +- crates/ui-egui/src/control.rs | 14 +- crates/ui-egui/src/credits.rs | 5 +- crates/ui-egui/src/credits_tests.rs | 7 +- crates/ui-egui/src/crop_presets.rs | 54 +- crates/ui-egui/src/crop_size.rs | 9 +- crates/ui-egui/src/crop_ui.rs | 2 +- crates/ui-egui/src/dds_options_ui.rs | 2 +- crates/ui-egui/src/dialog_blend_ui.rs | 4 +- crates/ui-egui/src/dialogs.rs | 29 +- crates/ui-egui/src/direction.rs | 4 +- crates/ui-egui/src/discard_ui.rs | 2 +- crates/ui-egui/src/doc_props_ui.rs | 22 +- crates/ui-egui/src/dock.rs | 14 +- crates/ui-egui/src/dock_tests.rs | 19 +- crates/ui-egui/src/duplicate_layer_ui.rs | 10 +- crates/ui-egui/src/egui_fonts.rs | 42 +- crates/ui-egui/src/eraser_ui.rs | 7 +- crates/ui-egui/src/export_dialog.rs | 26 +- crates/ui-egui/src/eyedropper_ui.rs | 4 +- crates/ui-egui/src/field_tab.rs | 6 +- crates/ui-egui/src/field_wheel.rs | 67 +- crates/ui-egui/src/field_wheel_app_tests.rs | 16 +- crates/ui-egui/src/file_ui.rs | 59 +- crates/ui-egui/src/file_ui/web_preview.rs | 4 +- crates/ui-egui/src/fill_ui.rs | 2 +- crates/ui-egui/src/filter_dialog.rs | 17 +- .../ui-egui/src/filter_dialog/zoom_center.rs | 2 +- crates/ui-egui/src/filter_preview_ui.rs | 4 +- crates/ui-egui/src/font_preview.rs | 4 +- crates/ui-egui/src/gallery_ui.rs | 10 +- crates/ui-egui/src/gpu_canvas.rs | 16 +- crates/ui-egui/src/gpu_status.rs | 2 +- crates/ui-egui/src/gradient_ui.rs | 12 +- .../src/gradient_ui/properties_panel.rs | 8 +- crates/ui-egui/src/guide_layout_ui.rs | 24 +- .../ui-egui/src/history_panel_select_tests.rs | 3 + crates/ui-egui/src/hold_keys.rs | 2 +- crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 9 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 11 + crates/ui-egui/src/i18n/id.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/mod.rs | 2 +- crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 10 + crates/ui-egui/src/i18n/zh-hant.tsv | 10 + crates/ui-egui/src/icons.rs | 6 +- crates/ui-egui/src/input_tests.rs | 19 +- crates/ui-egui/src/jobs_ui.rs | 6 +- crates/ui-egui/src/jobs_ui_tests.rs | 6 +- crates/ui-egui/src/jpeg_options_ui.rs | 11 +- crates/ui-egui/src/kys_import.rs | 2 +- crates/ui-egui/src/layer_filter_ui.rs | 6 +- crates/ui-egui/src/layer_menu_ui.rs | 8 +- .../ui-egui/src/layer_menu_ui/color_tests.rs | 14 +- crates/ui-egui/src/layer_pick_ui.rs | 2 +- crates/ui-egui/src/layer_props_ui.rs | 20 +- crates/ui-egui/src/layer_reveal.rs | 2 +- crates/ui-egui/src/layer_row_ui.rs | 18 +- .../ui-egui/src/layer_row_ui/rename_tests.rs | 2 +- crates/ui-egui/src/layer_style.rs | 22 +- crates/ui-egui/src/layer_style/contour.rs | 14 +- crates/ui-egui/src/layer_transfer.rs | 6 +- crates/ui-egui/src/layer_tree_ui.rs | 4 +- crates/ui-egui/src/layers_panel_ui.rs | 2 +- crates/ui-egui/src/layout_keys.rs | 2 +- crates/ui-egui/src/lib.rs | 455 ++- crates/ui-egui/src/lifecycle_tests.rs | 191 ++ crates/ui-egui/src/liquify_ui.rs | 26 +- crates/ui-egui/src/mask_props_ui.rs | 2 +- crates/ui-egui/src/mask_thumbs_ui.rs | 12 +- crates/ui-egui/src/menu_keys.rs | 4 +- crates/ui-egui/src/menu_keys_tests.rs | 2 + crates/ui-egui/src/menu_nav.rs | 35 +- crates/ui-egui/src/menu_nav_tests.rs | 9 +- crates/ui-egui/src/menus.rs | 22 +- crates/ui-egui/src/move_auto_select_tests.rs | 2 +- crates/ui-egui/src/new_doc_ui.rs | 31 +- crates/ui-egui/src/new_guide_ui.rs | 6 +- crates/ui-egui/src/new_layer_ui.rs | 12 +- crates/ui-egui/src/notices.rs | 2 +- crates/ui-egui/src/numeric_expression.rs | 2 +- .../src/options_bar_mode_wheel_tests.rs | 1 + crates/ui-egui/src/options_hit_area_tests.rs | 15 +- crates/ui-egui/src/paint_mouse.rs | 6 +- crates/ui-egui/src/palette.rs | 12 +- crates/ui-egui/src/panel_docking.rs | 30 +- crates/ui-egui/src/panel_docking_tests.rs | 29 +- crates/ui-egui/src/panels.rs | 403 ++- crates/ui-egui/src/paths_scroll_tests.rs | 1 + crates/ui-egui/src/pattern_fill_ui.rs | 2 +- crates/ui-egui/src/prefs_ui.rs | 75 +- crates/ui-egui/src/preset_panels.rs | 39 +- crates/ui-egui/src/press_menu.rs | 2 +- crates/ui-egui/src/pressure_curve_ui.rs | 4 +- crates/ui-egui/src/profile_prompt_tests.rs | 2 +- crates/ui-egui/src/props_layout.rs | 15 +- crates/ui-egui/src/puppet_ui.rs | 8 +- crates/ui-egui/src/retouch_ui.rs | 24 +- crates/ui-egui/src/rotate_view.rs | 4 +- crates/ui-egui/src/rulers.rs | 8 +- crates/ui-egui/src/screen_picker.rs | 16 +- crates/ui-egui/src/screenshot.rs | 648 +++++ crates/ui-egui/src/screenshot_tests.rs | 622 +++++ crates/ui-egui/src/select_mask_view.rs | 4 +- crates/ui-egui/src/shape_dialog.rs | 12 +- crates/ui-egui/src/shape_options.rs | 10 +- crates/ui-egui/src/shape_paint_ui.rs | 2 +- crates/ui-egui/src/shape_stroke_ui.rs | 10 +- crates/ui-egui/src/shape_stroke_ui/editor.rs | 10 +- crates/ui-egui/src/shape_stroke_ui/tests.rs | 2 +- crates/ui-egui/src/shortcut_dispatch.rs | 6 +- crates/ui-egui/src/shortcut_set_tests.rs | 8 +- crates/ui-egui/src/shortcuts.rs | 18 +- crates/ui-egui/src/single_line_drag.rs | 2 +- crates/ui-egui/src/sizing.rs | 16 +- crates/ui-egui/src/slice_ui.rs | 2 +- crates/ui-egui/src/smart_filter_dialog.rs | 4 +- crates/ui-egui/src/smart_ui.rs | 20 +- crates/ui-egui/src/stack_dialog.rs | 4 +- crates/ui-egui/src/status_bar_tests.rs | 2 +- crates/ui-egui/src/stroke_ui.rs | 4 +- crates/ui-egui/src/swatches_ui.rs | 6 +- crates/ui-egui/src/symmetry_ui.rs | 8 +- crates/ui-egui/src/symmetry_ui/tests.rs | 2 +- crates/ui-egui/src/tab_strip.rs | 16 +- crates/ui-egui/src/targa_options_ui.rs | 2 +- crates/ui-egui/src/text_field_menu.rs | 4 +- crates/ui-egui/src/theme.rs | 31 +- crates/ui-egui/src/tiff_options_ui.rs | 2 +- crates/ui-egui/src/tile_overlay.rs | 2 +- crates/ui-egui/src/titlebar.rs | 20 +- crates/ui-egui/src/tone.rs | 8 +- crates/ui-egui/src/tool_cursor.rs | 6 +- crates/ui-egui/src/tool_shortcuts.rs | 4 +- crates/ui-egui/src/transform_tool.rs | 37 +- crates/ui-egui/src/type_font_picker_tests.rs | 30 +- crates/ui-egui/src/type_font_preview.rs | 6 +- crates/ui-egui/src/type_panels_ui.rs | 55 +- .../src/type_panels_ui/color_picker.rs | 2 +- crates/ui-egui/src/type_tool.rs | 2430 ++++++++++++++++- crates/ui-egui/src/type_tool_tests.rs | 39 +- crates/ui-egui/src/type_transform.rs | 4 +- crates/ui-egui/src/updates_ui.rs | 2 +- crates/ui-egui/src/variables_ui.rs | 26 +- crates/ui-egui/src/vector_ui.rs | 29 +- crates/ui-egui/src/view_cmds.rs | 12 +- crates/ui-egui/src/vp_ui.rs | 4 +- crates/ui-egui/src/wheel_nav.rs | 55 +- crates/ui-egui/src/wide_angle_ui.rs | 6 +- crates/ui-egui/src/widgets.rs | 257 +- crates/ui-egui/src/widgets/angle_dial.rs | 6 +- crates/ui-egui/src/widgets/color_count.rs | 2 +- .../src/widgets/numeric_input_tests.rs | 8 +- crates/ui-egui/src/widgets/type_ahead.rs | 4 +- crates/ui-egui/src/work_area.rs | 2 +- crates/ui-egui/src/workspace_ui.rs | 21 +- crates/ui-egui/src/zoom_hold.rs | 2 +- crates/ui-egui/src/zoom_tool.rs | 3 +- crates/ui-egui/src/zoom_window_fit.rs | 2 +- .../tests/it/arbitrary_rotation_preview.rs | 2 +- .../ui-egui/tests/it/batch_export_options.rs | 50 +- .../ui-egui/tests/it/camera_raw_histogram.rs | 31 +- .../tests/it/camera_raw_localization.rs | 44 +- crates/ui-egui/tests/it/camera_raw_scope.rs | 9 +- crates/ui-egui/tests/it/camera_raw_zoom.rs | 8 +- crates/ui-egui/tests/it/custom_print_paper.rs | 18 +- crates/ui-egui/tests/it/export_as_scaling.rs | 30 +- docs/control-protocol.md | 14 +- docs/egui-037-migration-plan.md | 1636 +++++++++++ packaging/env.sh | 2 +- .../flatpak/ai.storyteller.photocraft.yml | 1 + packaging/linux/package.sh | 1 - packaging/macos/package.sh | 2 + packaging/windows/package.ps1 | 18 +- packaging/windows/photocraft.wxs | 11 + 246 files changed, 8721 insertions(+), 1590 deletions(-) create mode 100644 apps/photocraft/tests/fixtures/README.md create mode 100644 apps/photocraft/tests/fixtures/ui-0.14.ron create mode 100644 apps/photocraft/tests/fixtures/ui-0.15.ron create mode 100644 apps/photocraft/tests/fixtures/ui-0.37.ron create mode 100644 crates/ui-egui/src/lifecycle_tests.rs create mode 100644 crates/ui-egui/src/screenshot.rs create mode 100644 crates/ui-egui/src/screenshot_tests.rs create mode 100644 docs/egui-037-migration-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 981500770b..cea1326c0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index f15ec0e8d8..2b98d8c11d 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfdca9e3e9..4b78fac526 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5b2b1e1470..9a64f8f96f 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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) | | 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 | | 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 | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | diff --git a/Cargo.lock b/Cargo.lock index 794e35b1ff..e24325e9f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] @@ -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,7 +1330,7 @@ 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", @@ -1675,9 +1670,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 +1680,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 +1704,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1723,9 +1717,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 +1738,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 +1757,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 +1797,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 +1810,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 +1836,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 +1865,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 +1917,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 +1935,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" @@ -2534,9 +2526,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", @@ -3489,12 +3481,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]] @@ -4699,9 +4691,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 +4702,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 +4712,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 +4725,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", ] @@ -4776,6 +4768,7 @@ dependencies = [ "resvg", "rfd", "ron", + "serde", "serde_json", "tempfile", "thiserror 2.0.21", @@ -5198,6 +5191,7 @@ dependencies = [ "tempfile", "toml", "unicode-bidi", + "web-time", "winsafe", ] @@ -6401,6 +6395,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -7692,9 +7692,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", @@ -7707,9 +7707,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", @@ -8602,8 +8602,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", @@ -9240,3 +9239,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" diff --git a/Cargo.toml b/Cargo.toml index 43e4c87e27..5f2280a7ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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,7 +41,10 @@ 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" } thiserror = "2" @@ -56,11 +61,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 +73,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 +183,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" } diff --git a/NOTICE b/NOTICE index a7302a940c..6250865673 100644 --- a/NOTICE +++ b/NOTICE @@ -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-.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. diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 279d13b453..0176e0fe84 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -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" diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 20e64fa046..d9d4f5d94e 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -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()); } } diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index d8e7568300..48f8cba08b 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -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 } diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 7fbf765129..a0b32ab9d5 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -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")), @@ -411,6 +414,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 +672,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); + impl eframe::Storage for Storage { + fn get_string(&self, key: &str) -> Option { + 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); diff --git a/apps/photocraft/src/ui_state.rs b/apps/photocraft/src/ui_state.rs index c4de67a994..ad448a6402 100644 --- a/apps/photocraft/src/ui_state.rs +++ b/apps/photocraft/src/ui_state.rs @@ -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`, …) 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 = 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::>(text) else { return false }; + let Ok(kv) = ron::from_str::>(text) else { return false }; let Some(window) = kv.get("window").and_then(|w| ron::from_str::(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, 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::>(text) else { return Ok(()) }; + let Ok(kv) = ron::from_str::>(text) else { return Ok(()) }; for (key, value) in &kv { // eframe also skips an entry that doesn't parse. let Ok(v) = ron::from_str::(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, String> { + let Ok(mut kv) = ron::from_str::>(&text) else { return Ok(None) }; + let Some(value) = kv.get("window").cloned() else { return Ok(None) }; + let Ok(window) = ron::from_str::(&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 { + 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, + 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 { + 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 { + 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` field, rejecting corrupt values rather than guessing. +fn old_position(window: &ron::Value, key: &str) -> Option { + 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 { + 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 { } } +fn as_f32(v: &ron::Value) -> Option { + 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 = 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 = 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, + outer_position_pixels: Option, + fullscreen: bool, + maximized: bool, + inner_size_points: Option, + } + + #[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")); + } } diff --git a/apps/photocraft/tests/fixtures/README.md b/apps/photocraft/tests/fixtures/README.md new file mode 100644 index 0000000000..1eb90452f0 --- /dev/null +++ b/apps/photocraft/tests/fixtures/README.md @@ -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. diff --git a/apps/photocraft/tests/fixtures/ui-0.14.ron b/apps/photocraft/tests/fixtures/ui-0.14.ron new file mode 100644 index 0000000000..619999cf2b --- /dev/null +++ b/apps/photocraft/tests/fixtures/ui-0.14.ron @@ -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":"()"} diff --git a/apps/photocraft/tests/fixtures/ui-0.15.ron b/apps/photocraft/tests/fixtures/ui-0.15.ron new file mode 100644 index 0000000000..7edccd600b --- /dev/null +++ b/apps/photocraft/tests/fixtures/ui-0.15.ron @@ -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":"()"} diff --git a/apps/photocraft/tests/fixtures/ui-0.37.ron b/apps/photocraft/tests/fixtures/ui-0.37.ron new file mode 100644 index 0000000000..d5973c734f --- /dev/null +++ b/apps/photocraft/tests/fixtures/ui-0.37.ron @@ -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":"()"} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 5c26e41cba..766f1767cf 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -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 } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 44cbe903ad..99bcbe589e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -470,6 +470,12 @@ pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.n pub struct Session { docs: Vec, active: Option, + /// 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, 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) } diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 9949bddea0..79081006ad 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -1068,6 +1068,7 @@ pub fn commit_child(s: &mut Session, index: usize) -> Result { 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 { Ok(()) }); s.active = prev; + s.note_active_change(); r?; if let Some(st) = s.docs.get_mut(index) { st.saved_revision = st.revision; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9e06c966de..954d0c4635 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1823,3 +1823,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); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 23ecfc3b09..e66d67ab0d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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,6 +40,7 @@ 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] rayon = { workspace = true } @@ -54,7 +55,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 +71,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" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 900370eb69..df65585d7e 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -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), diff --git a/crates/ui-egui/examples/color_picker_demo.rs b/crates/ui-egui/examples/color_picker_demo.rs index 45daa45c12..f7c304a007 100644 --- a/crates/ui-egui/examples/color_picker_demo.rs +++ b/crates/ui-egui/examples/color_picker_demo.rs @@ -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), diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 946a246d62..2c5e423314 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -454,9 +454,9 @@ fn insert_stop_dialog(app: &mut PhotocraftApp) -> Result { } pub fn insert_stop_body(ui: &mut egui::Ui, fields: &mut serde_json::Map) { - 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 { 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; } diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 1c45da190b..24589b9526 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -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) -> 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::(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::(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::(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::Id::new(("dialog-place", "image.adjustments.curves")).with("size"))); + let kept = h.ctx.data_mut(|m| m.get_persisted::(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::(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::>())); h.run_steps(4); - let graph = h.ctx.data(|d| d.get_temp::(egui::Id::new(("adjust-dialog", "curves")).with("curves-graph"))).unwrap(); + let graph = h.ctx.data(|d| d.get_temp::(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::Id::new(("adjust-dialog", "curves")).with("curves-graph"))).unwrap(); + let graph = h.ctx.data(|d| d.get_temp::(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); diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 4e6e738863..43217a10f6 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -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, 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, diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 3d0b76ab11..2bbff7d596 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -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 { - // 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::(egui::Id::new(("adjust-layer", id.0)).with("curves-graph"))).expect("curves graph drawn") + h.ctx.data(|d| d.get_temp::(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::(egui::Id::new("hs").with("hs-bars"))).expect("bars drawn"); - let avail = h.ctx.data(|d| d.get_temp::(egui::Id::new("hs-avail"))).unwrap(); + let bars = h.ctx.data(|d| d.get_temp::(egui::Id::unique("hs").with("hs-bars"))).expect("bars drawn"); + let avail = h.ctx.data(|d| d.get_temp::(egui::Id::unique("hs-avail"))).unwrap(); assert!((bars.width() - avail).abs() < 0.5, "{width}: bars {} of {avail}", bars.width()); } } diff --git a/crates/ui-egui/src/adjust_ui.rs b/crates/ui-egui/src/adjust_ui.rs index 927197144b..ae6722e574 100644 --- a/crates/ui-egui/src/adjust_ui.rs +++ b/crates/ui-egui/src/adjust_ui.rs @@ -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::(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 = 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 diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index d62de17348..f3c1594b7b 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -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 = ¬es[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, 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, 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 = 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)); } }); diff --git a/crates/ui-egui/src/apply_color.rs b/crates/ui-egui/src/apply_color.rs index 602b590b6a..28d76113a4 100644 --- a/crates/ui-egui/src/apply_color.rs +++ b/crates/ui-egui/src/apply_color.rs @@ -63,7 +63,7 @@ pub fn gesture(ctx: &Context) -> Option { } 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 diff --git a/crates/ui-egui/src/artboard_tool.rs b/crates/ui-egui/src/artboard_tool.rs index 7f8ee838c6..81dd67bd02 100644 --- a/crates/ui-egui/src/artboard_tool.rs +++ b/crates/ui-egui/src/artboard_tool.rs @@ -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)})); } diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs index 408b398303..3af0e2ac3a 100644 --- a/crates/ui-egui/src/artboard_ui.rs +++ b/crates/ui-egui/src/artboard_ui.rs @@ -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| -> 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| -> 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 { diff --git a/crates/ui-egui/src/background_redraw.rs b/crates/ui-egui/src/background_redraw.rs index 778243f1d6..53ab20f73f 100644 --- a/crates/ui-egui/src/background_redraw.rs +++ b/crates/ui-egui/src/background_redraw.rs @@ -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(); } } diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs index a6ea8e5052..08152b82e8 100644 --- a/crates/ui-egui/src/background_redraw_tests.rs +++ b/crates/ui-egui/src/background_redraw_tests.rs @@ -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); } diff --git a/crates/ui-egui/src/bitmap_dialog.rs b/crates/ui-egui/src/bitmap_dialog.rs index 04ef5b1d79..d7ff658176 100644 --- a/crates/ui-egui/src/bitmap_dialog.rs +++ b/crates/ui-egui/src/bitmap_dialog.rs @@ -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, 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, 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 = 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 = 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)); } }); diff --git a/crates/ui-egui/src/blend_dropdown_wheel_tests.rs b/crates/ui-egui/src/blend_dropdown_wheel_tests.rs index c6b9186f86..f3f69cdd45 100644 --- a/crates/ui-egui/src/blend_dropdown_wheel_tests.rs +++ b/crates/ui-egui/src/blend_dropdown_wheel_tests.rs @@ -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, diff --git a/crates/ui-egui/src/brand.rs b/crates/ui-egui/src/brand.rs index a601e95c3e..01cf827d35 100644 --- a/crates/ui-egui/src/brand.rs +++ b/crates/ui-egui/src/brand.rs @@ -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::(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). diff --git a/crates/ui-egui/src/brush_cursor.rs b/crates/ui-egui/src/brush_cursor.rs index 22a489ba9a..6bc71a78f7 100644 --- a/crates/ui-egui/src/brush_cursor.rs +++ b/crates/ui-egui/src/brush_cursor.rs @@ -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::(id)) && c.key.matches(brush, full) { diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 649a50eb46..4b46204266 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -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) diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 1ea52bdb72..e0e15cb4f5 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -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); diff --git a/crates/ui-egui/src/brush_picker.rs b/crates/ui-egui/src/brush_picker.rs index 9bef83a647..e417dc5a77 100644 --- a/crates/ui-egui/src/brush_picker.rs +++ b/crates/ui-egui/src/brush_picker.rs @@ -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 { 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 { diff --git a/crates/ui-egui/src/brush_preset_dialog.rs b/crates/ui-egui/src/brush_preset_dialog.rs index 129b9adf28..46bf07522e 100644 --- a/crates/ui-egui/src/brush_preset_dialog.rs +++ b/crates/ui-egui/src/brush_preset_dialog.rs @@ -96,8 +96,8 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, 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::(once).unwrap_or(false)) { diff --git a/crates/ui-egui/src/brush_preview.rs b/crates/ui-egui/src/brush_preview.rs index ec6b36c07b..f77975d45c 100644 --- a/crates/ui-egui/src/brush_preview.rs +++ b/crates/ui-egui/src/brush_preview.rs @@ -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, diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs index 7d41aaa1ba..35918bbbc4 100644 --- a/crates/ui-egui/src/brush_resize.rs +++ b/crates/ui-egui/src/brush_resize.rs @@ -308,7 +308,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)); @@ -370,7 +370,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)); diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ca1556c680..d75a4daad1 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -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(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"); diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index 78f74811a9..050d810603 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -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) { 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 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"; diff --git a/crates/ui-egui/src/brushes_tab_tests.rs b/crates/ui-egui/src/brushes_tab_tests.rs index e5cf21b74e..85bf63d38e 100644 --- a/crates/ui-egui/src/brushes_tab_tests.rs +++ b/crates/ui-egui/src/brushes_tab_tests.rs @@ -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 diff --git a/crates/ui-egui/src/caf_ui.rs b/crates/ui-egui/src/caf_ui.rs index 149474b260..04bd6e05f5 100644 --- a/crates/ui-egui/src/caf_ui.rs +++ b/crates/ui-egui/src/caf_ui.rs @@ -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 diff --git a/crates/ui-egui/src/camera_raw_preview_ui.rs b/crates/ui-egui/src/camera_raw_preview_ui.rs index e2d15f9e0c..cae9f1410a 100644 --- a/crates/ui-egui/src/camera_raw_preview_ui.rs +++ b/crates/ui-egui/src/camera_raw_preview_ui.rs @@ -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) { 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, ¤t_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(), diff --git a/crates/ui-egui/src/camera_raw_scope_ui.rs b/crates/ui-egui/src/camera_raw_scope_ui.rs index 4e93b72df9..daf0a4855f 100644 --- a/crates/ui-egui/src/camera_raw_scope_ui.rs +++ b/crates/ui-egui/src/camera_raw_scope_ui.rs @@ -344,8 +344,8 @@ fn readout_text(value: Option) -> 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) diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index f78d467b7c..118e6fd10e 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -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; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ab3e6939a7..093460757d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -550,22 +550,27 @@ pub(crate) fn draw_readout(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, @@ -1578,7 +1583,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 }) @@ -1599,7 +1604,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::(key)).unwrap_or(0); if waited >= NAVIGATOR_WAIT_FRAMES { return false; @@ -2737,11 +2742,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 { @@ -2759,7 +2764,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); } @@ -2786,7 +2791,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); @@ -2798,7 +2803,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); } @@ -2819,7 +2824,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 = (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), @@ -2911,7 +2916,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 { @@ -3082,7 +3087,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); @@ -3095,10 +3100,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; @@ -3123,7 +3128,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); @@ -3133,8 +3138,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() { @@ -3152,7 +3157,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 = (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), @@ -3235,7 +3240,7 @@ fn dots(ctx: &egui::Context, t: &crate::theme::Tokens) -> Option(key)) { return Some(tex.id()); } @@ -3285,7 +3290,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(); } } @@ -3424,12 +3429,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() { @@ -3541,7 +3546,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| { @@ -3668,7 +3673,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), @@ -3690,7 +3695,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), @@ -3706,8 +3711,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). @@ -3920,7 +3928,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::(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). @@ -4143,7 +4151,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); @@ -4186,7 +4194,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); @@ -4200,7 +4208,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 { @@ -4911,7 +4919,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(); @@ -5006,7 +5014,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); @@ -5310,7 +5318,7 @@ fn ring(outer: DRect, inner: DRect) -> Vec { fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, output: Option) -> 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 { @@ -8099,7 +8107,7 @@ mod tests { let revision = app.session.active().unwrap().revision; let mut output = ctx.run_ui(Default::default(), |ui| { let ctx = ui.ctx(); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("clone-test"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); }); output.textures_delta.clear(); @@ -8119,7 +8127,7 @@ mod tests { let ordinary_key = app.clone_preview.as_ref().unwrap().key.clone(); app.ui.view.pattern_preview = true; let mut periodic_output = 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(&mut app, &painter, &xf, [32.0, 32.0], None); }); assert_ne!(app.clone_preview.as_ref().unwrap().key, ordinary_key, "toggling Pattern Preview invalidates source pixels"); @@ -8129,7 +8137,7 @@ mod tests { app.session.presets.clone.overlay.show = false; let mut output = ctx.run_ui(Default::default(), |ui| { let ctx = ui.ctx(); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("clone-test"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test"))); draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); }); output.textures_delta.clear(); @@ -8173,7 +8181,7 @@ mod tests { let mut centre = |mode: &str| { app.run("cloneSource.overlay", json!({"blend": mode})).unwrap(); let mut output = 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(&mut app, &painter, &xf, [32.0, 32.0], None); }); // The brush's 20 × 20 px square (the font atlas may upload too). @@ -8204,7 +8212,7 @@ mod tests { let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }; let mut output = 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(&mut app, &painter, &xf, [32.0, 32.0], None); }); let image = output diff --git a/crates/ui-egui/src/canvas_menu_tests.rs b/crates/ui-egui/src/canvas_menu_tests.rs index d2a5c7a2b9..dfed592653 100644 --- a/crates/ui-egui/src/canvas_menu_tests.rs +++ b/crates/ui-egui/src/canvas_menu_tests.rs @@ -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. diff --git a/crates/ui-egui/src/canvas_popup.rs b/crates/ui-egui/src/canvas_popup.rs index 4f6e7dfc8d..83ecb7d27b 100644 --- a/crates/ui-egui/src/canvas_popup.rs +++ b/crates/ui-egui/src/canvas_popup.rs @@ -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| { diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index e4256949ee..a764145b0c 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -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) { diff --git a/crates/ui-egui/src/cas_transform.rs b/crates/ui-egui/src/cas_transform.rs index d5b263d6e8..0a1fa7d5b5 100644 --- a/crates/ui-egui/src/cas_transform.rs +++ b/crates/ui-egui/src/cas_transform.rs @@ -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 = app.session.active().map(|st| st.doc.channels.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); let mut options: Vec<(Option, &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() diff --git a/crates/ui-egui/src/channel_options_ui.rs b/crates/ui-egui/src/channel_options_ui.rs index 7ff77d1146..d35ed2ff07 100644 --- a/crates/ui-egui/src/channel_options_ui.rs +++ b/crates/ui-egui/src/channel_options_ui.rs @@ -110,7 +110,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { 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) { 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) { 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) { 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) { 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())); } }); diff --git a/crates/ui-egui/src/channels_panel.rs b/crates/ui-egui/src/channels_panel.rs index 589d9f8123..5768ae0850 100644 --- a/crates/ui-egui/src/channels_panel.rs +++ b/crates/ui-egui/src/channels_panel.rs @@ -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::(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, 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> = (0..4) .map(|_| { diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index 51b043a21e..7822d13860 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -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; diff --git a/crates/ui-egui/src/cjk_fonts.rs b/crates/ui-egui/src/cjk_fonts.rs index e4c2739fb4..6d3279d1ea 100644 --- a/crates/ui-egui/src/cjk_fonts.rs +++ b/crates/ui-egui/src/cjk_fonts.rs @@ -50,6 +50,9 @@ pub struct Sources { pub covers: fn(&[u8], char) -> Option, /// 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 = 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) -> 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é")); diff --git a/crates/ui-egui/src/clip_line_ui.rs b/crates/ui-egui/src/clip_line_ui.rs index 2efcb73297..f879edb179 100644 --- a/crates/ui-egui/src/clip_line_ui.rs +++ b/crates/ui-egui/src/clip_line_ui.rs @@ -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" }; diff --git a/crates/ui-egui/src/clipping_indicator_tests.rs b/crates/ui-egui/src/clipping_indicator_tests.rs index d95bdedf27..99c22ecf10 100644 --- a/crates/ui-egui/src/clipping_indicator_tests.rs +++ b/crates/ui-egui/src/clipping_indicator_tests.rs @@ -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(); diff --git a/crates/ui-egui/src/color_panel_ui.rs b/crates/ui-egui/src/color_panel_ui.rs index 88028502e8..19f223b8a2 100644 --- a/crates/ui-egui/src/color_panel_ui.rs +++ b/crates/ui-egui/src/color_panel_ui.rs @@ -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>)>(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)})) { diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index ece2fbafb3..2c0286a4c9 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -431,7 +431,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) -> Option { // 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); } }); @@ -536,8 +536,8 @@ fn swatches(ui: &mut egui::Ui, f: &mut Map) { 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); } @@ -558,8 +558,8 @@ fn swatches(ui: &mut egui::Ui, f: &mut Map) { } 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); } @@ -598,7 +598,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() } @@ -623,7 +623,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, 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 }; @@ -647,7 +647,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, 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)); @@ -668,7 +668,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, 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)); } @@ -695,7 +695,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, 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))); @@ -709,7 +709,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, 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))); @@ -886,14 +886,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); @@ -931,14 +931,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); @@ -1115,7 +1115,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"] { diff --git a/crates/ui-egui/src/color_range_ui.rs b/crates/ui-egui/src/color_range_ui.rs index 59b3bfeb2f..a21d3ef71f 100644 --- a/crates/ui-egui/src/color_range_ui.rs +++ b/crates/ui-egui/src/color_range_ui.rs @@ -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 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 "", }; 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"); diff --git a/crates/ui-egui/src/color_tab_theme_tests.rs b/crates/ui-egui/src/color_tab_theme_tests.rs index 7996284d94..8fdfb0b48f 100644 --- a/crates/ui-egui/src/color_tab_theme_tests.rs +++ b/crates/ui-egui/src/color_tab_theme_tests.rs @@ -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"); } } } diff --git a/crates/ui-egui/src/comps_ui.rs b/crates/ui-egui/src/comps_ui.rs index 29f5286024..bcff54de4a 100644 --- a/crates/ui-egui/src/comps_ui.rs +++ b/crates/ui-egui/src/comps_ui.rs @@ -36,7 +36,7 @@ struct RenameComp { fn rename_field(ui: &mut egui::Ui, id: egui::Id, rect: Rect) -> Option { let mut r = ui.data(|d| d.get_temp::(id))?; ui.data_mut(|d| d.remove::(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::() && 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::(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) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4bffb2ec4e..4c7d8d1cba 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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 = 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 { diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index b31aa40578..eef2612379 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -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::(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>) -> 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(); diff --git a/crates/ui-egui/src/credits_tests.rs b/crates/ui-egui/src/credits_tests.rs index 4ac3f3e41f..8201c1960b 100644 --- a/crates/ui-egui/src/credits_tests.rs +++ b/crates/ui-egui/src/credits_tests.rs @@ -40,7 +40,7 @@ fn harness(list: Vec, 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::(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 { } fn view(h: &Harness<'_>) -> View { - h.ctx.data(|d| d.get_temp::(egui::Id::new("credits_view"))).unwrap_or_default() + h.ctx.data(|d| d.get_temp::(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. diff --git a/crates/ui-egui/src/crop_presets.rs b/crates/ui-egui/src/crop_presets.rs index 9b919c3b5e..107f41830f 100644 --- a/crates/ui-egui/src/crop_presets.rs +++ b/crates/ui-egui/src/crop_presets.rs @@ -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 = 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) { 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::(once).unwrap_or(false)) { diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index 71fc57f495..c6ea7b69a9 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -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::(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 }; diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 73e9ce5642..d922aa3290 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -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; diff --git a/crates/ui-egui/src/dds_options_ui.rs b/crates/ui-egui/src/dds_options_ui.rs index 3216b51a30..6864e098c6 100644 --- a/crates/ui-egui/src/dds_options_ui.rs +++ b/crates/ui-egui/src/dds_options_ui.rs @@ -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); diff --git a/crates/ui-egui/src/dialog_blend_ui.rs b/crates/ui-egui/src/dialog_blend_ui.rs index 0eb8207eb5..8ff086cebe 100644 --- a/crates/ui-egui/src/dialog_blend_ui.rs +++ b/crates/ui-egui/src/dialog_blend_ui.rs @@ -27,14 +27,14 @@ pub fn body(ui: &mut egui::Ui, fields: &mut Map, 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())); } }); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2c683bce38..d0994008e6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { - 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::(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(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 = - 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 = 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| { - 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 = h.ctx.data(|m| m.get_temp(egui::Id::new(RECTS))).unwrap(); + let rects: Vec = h.ctx.data(|m| m.get_temp(egui::Id::unique(RECTS))).unwrap(); rects[0] } diff --git a/crates/ui-egui/src/direction.rs b/crates/ui-egui/src/direction.rs index ab54a2ed62..6eabbb73ef 100644 --- a/crates/ui-egui/src/direction.rs +++ b/crates/ui-egui/src/direction.rs @@ -68,12 +68,12 @@ fn parse_dir(value: &str) -> Option { } /// 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::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::Panel { +pub fn end_panel(id: impl egui::AsIdSalt) -> egui::Panel { if is_rtl() { egui::Panel::left(id) } else { egui::Panel::right(id) } } diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index a6a65f29fe..2570e9041c 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -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::>()); let row: Vec = 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); diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index 90f70dd77e..3926285b20 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -70,7 +70,7 @@ pub fn canvas_resize_params(old: (u32, u32), new_w: Option, new_h: Option 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::(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 { - 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 { + 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::(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::(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}))); } }); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 1e4fa3cf53..0e15a93b9b 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -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 { } 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; diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index e88bdf10ea..2d8952c15e 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -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] diff --git a/crates/ui-egui/src/duplicate_layer_ui.rs b/crates/ui-egui/src/duplicate_layer_ui.rs index c1b76c0ead..3d9b0edc8f 100644 --- a/crates/ui-egui/src/duplicate_layer_ui.rs +++ b/crates/ui-egui/src/duplicate_layer_ui.rs @@ -48,21 +48,21 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map> = LazyLock::new(|| inflate(deflated!("Hack-Regular"))); +static EGUI_ICONS: LazyLock> = LazyLock::new(|| inflate(deflated!("egui-icons"))); static NOTO_EMOJI: LazyLock> = LazyLock::new(|| inflate(deflated!("NotoEmoji-Regular"))); static UBUNTU_LIGHT: LazyLock> = LazyLock::new(|| inflate(deflated!("Ubuntu-Light"))); static EMOJI_ICON: LazyLock> = 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 { 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()); diff --git a/crates/ui-egui/src/eraser_ui.rs b/crates/ui-egui/src/eraser_ui.rs index f92c3427e9..ac896eeb0b 100644 --- a/crates/ui-egui/src/eraser_ui.rs +++ b/crates/ui-egui/src/eraser_ui.rs @@ -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")); diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 29ee74b1aa..c014947aa3 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -533,7 +533,7 @@ fn poll_or_start_preview( doc: Arc, fields: &Map, ) -> Option> { - 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 = 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()?; @@ -659,7 +659,7 @@ fn size_fields(ui: &mut egui::Ui, f: &mut Map, 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 @@ -672,7 +672,7 @@ fn size_fields(ui: &mut egui::Ui, f: &mut Map, 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 @@ -684,7 +684,7 @@ fn size_fields(ui: &mut egui::Ui, f: &mut Map, 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 @@ -709,7 +709,7 @@ fn resample_field(ui: &mut egui::Ui, f: &mut Map) { 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)); } }); @@ -788,7 +788,7 @@ fn preset_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = 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); } }); @@ -879,7 +879,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = 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)); } }); @@ -893,7 +893,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = 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)); } }); @@ -904,7 +904,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = 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)); } }); @@ -927,7 +927,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map 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; @@ -1562,7 +1562,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| { diff --git a/crates/ui-egui/src/eyedropper_ui.rs b/crates/ui-egui/src/eyedropper_ui.rs index 4b50d4c33a..1a225592af 100644 --- a/crates/ui-egui/src/eyedropper_ui.rs +++ b/crates/ui-egui/src/eyedropper_ui.rs @@ -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()); diff --git a/crates/ui-egui/src/field_tab.rs b/crates/ui-egui/src/field_tab.rs index 467c7d8b50..79d8217029 100644 --- a/crates/ui-egui/src/field_tab.rs +++ b/crates/ui-egui/src/field_tab.rs @@ -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 diff --git a/crates/ui-egui/src/field_wheel.rs b/crates/ui-egui/src/field_wheel.rs index 92b15965dc..f4d0ac80d2 100644 --- a/crates/ui-egui/src/field_wheel.rs +++ b/crates/ui-egui/src/field_wheel.rs @@ -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:?}"); diff --git a/crates/ui-egui/src/field_wheel_app_tests.rs b/crates/ui-egui/src/field_wheel_app_tests.rs index 6d4f56db3e..61ff68d2ea 100644 --- a/crates/ui-egui/src/field_wheel_app_tests.rs +++ b/crates/ui-egui/src/field_wheel_app_tests.rs @@ -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); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index ecc18cc2c2..39b0278b31 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -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 = ui.data(|d| d.get_temp(key)); let (tex, info) = match cached.filter(|c| c.0 == sig) { Some((_, tex, info)) => (tex, info), @@ -396,10 +396,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, key: &str, opts: &[(&str, &str)], w: f32) { +fn dropdown_str(ui: &mut egui::Ui, id: &str, name: &str, f: &mut Map, 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)); } } @@ -415,7 +415,7 @@ fn number(ui: &mut egui::Ui, f: &mut Map, 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())); }); } @@ -543,14 +543,14 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = 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() { @@ -558,6 +558,7 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, doc: &photocraft_doc: dropdown_str( ui, "web-resample", + tl!("Resample"), f, "resample", &[ @@ -944,7 +949,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; @@ -972,7 +977,7 @@ fn print_paper_controls(ui: &mut egui::Ui, f: &mut Map) { 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])); @@ -983,7 +988,7 @@ fn print_paper_controls(ui: &mut egui::Ui, f: &mut Map) { 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")); @@ -1092,10 +1097,10 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map(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> = ui.data(|d| d.get_temp(key)); let tex = tex.unwrap_or_else(|| { let th = photocraft_compose::thumbnail(&doc, 400); @@ -1182,7 +1187,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = 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); @@ -1231,7 +1236,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = 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)); } }); @@ -1242,6 +1247,7 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = h.ctx.data(|m| m.get_temp(egui::Id::new("pc-dialog-rects"))).unwrap(); + let rects: Vec = 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(); @@ -2116,9 +2123,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})"))); } } @@ -2138,7 +2145,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)>(key)).unwrap().1; let first = encoded(&h.ctx); h.state_mut().1.insert("colorTableEdits".into(), json!({"0":"#ffff00"})); diff --git a/crates/ui-egui/src/file_ui/web_preview.rs b/crates/ui-egui/src/file_ui/web_preview.rs index 02cf44cb6d..44399b7d52 100644 --- a/crates/ui-egui/src/file_ui/web_preview.rs +++ b/crates/ui-egui/src/file_ui/web_preview.rs @@ -219,7 +219,7 @@ pub fn controls(ui: &mut egui::Ui, f: &mut Map) { 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)| { diff --git a/crates/ui-egui/src/fill_ui.rs b/crates/ui-egui/src/fill_ui.rs index faa917f039..7e2d3e17ed 100644 --- a/crates/ui-egui/src/fill_ui.rs +++ b/crates/ui-egui/src/fill_ui.rs @@ -155,7 +155,7 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) 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" { diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index b611b9ac64..7f0ae8e998 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -829,9 +829,10 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { // 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) { 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::().ok()) { f.insert("depthChannel".into(), json!(i)); cur = "channel".into(); @@ -909,8 +910,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { 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) { 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::() { 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) { 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) { 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() { diff --git a/crates/ui-egui/src/filter_dialog/zoom_center.rs b/crates/ui-egui/src/filter_dialog/zoom_center.rs index 3acb09b052..963b7c16b4 100644 --- a/crates/ui-egui/src/filter_dialog/zoom_center.rs +++ b/crates/ui-egui/src/filter_dialog/zoom_center.rs @@ -39,7 +39,7 @@ pub(super) fn row(ui: &mut Ui, fields: &mut Map) { }); // 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)] { diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 0dd76f4f45..f83a260b0d 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -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_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_id).unwrap().original.unwrap().tex.id()); app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(1); diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index 678da4191c..58b1b20b15 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -123,7 +123,7 @@ fn key(ctx: &egui::Context, family: &str, size: f32) -> Key { pub(crate) fn slot_width(ctx: &egui::Context, family: &str, size: f32) -> f32 { let key = key(ctx, family, size); let arabic = ctx.data_mut(|d| { - let cache = d.get_temp_mut_or_default::(egui::Id::new(CACHE_ID)); + let cache = d.get_temp_mut_or_default::(egui::Id::unique(CACHE_ID)); cache.0.iter().any(|(k, samples)| k == &key && samples.as_ref().is_some_and(|s| s.arabic.is_some())) }); if arabic { scaled(WIDTH, size) + GAP + scaled(ARABIC_WIDTH, size) } else { scaled(WIDTH, size) } @@ -133,7 +133,7 @@ pub(crate) fn paint(ui: &egui::Ui, family: &str, row: Rect, size: f32) { let ctx = ui.ctx(); let scale = ctx.pixels_per_point().ceil().clamp(1.0, 4.0) as u32; let key = key(ctx, family, size); - let id = egui::Id::new(CACHE_ID); + let id = egui::Id::unique(CACHE_ID); let cached = ctx.data_mut(|d| { let cache = d.get_temp_mut_or_default::(id); let index = cache.0.iter().position(|(k, _)| k == &key)?; diff --git a/crates/ui-egui/src/gallery_ui.rs b/crates/ui-egui/src/gallery_ui.rs index 8a99bd4fbc..17f671e1a7 100644 --- a/crates/ui-egui/src/gallery_ui.rs +++ b/crates/ui-egui/src/gallery_ui.rs @@ -638,7 +638,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let screen = ctx.content_rect(); let mut action: Option<&str> = None; let background = app.background_jobs; - egui::Area::new(egui::Id::new("gallery-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("gallery-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.distort.gallery.as_mut() else { return }; d.history.settle(&d.effects, ctx.input(|i| i.pointer.any_down())); let (full, _) = ui.allocate_exact_size(screen.size(), Sense::hover()); @@ -665,7 +665,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { clip.image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); clip.rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); } - let resp = ui.interact(area, egui::Id::new("gallery-preview"), Sense::drag()); + let resp = ui.interact(area, egui::Id::unique("gallery-preview"), Sense::drag()); if resp.dragged() && d.zoom > 0.0 { let dl = resp.drag_delta() / d.zoom; d.center = [d.center[0] - dl.x, d.center[1] - dl.y]; @@ -688,7 +688,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let at = opts.iter().position(|(v, _)| *v > z).unwrap_or(opts.len()); opts.insert(at, (z, &off_menu)); } - if widgets::dropdown(&mut zb, "gallery-zoom", &mut z, &opts, 110.0) { + if widgets::dropdown(&mut zb, "gallery-zoom", tl!("Zoom"), &mut z, &opts, 110.0) { d.zoom = z; } if let Some(p) = d.rendering() { @@ -773,7 +773,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ru.add_space(4.0); let mut cur = d.effects[d.selected].filter; let names: Vec<(GalleryFilter, &str)> = GalleryFilter::ALL.iter().map(|f| (*f, f.name())).collect(); - if widgets::dropdown(&mut ru, "gallery-filter", &mut cur, &names, inner.width() - 8.0) { + if widgets::dropdown(&mut ru, "gallery-filter", tl!("Filter"), &mut cur, &names, inner.width() - 8.0) { d.set_filter(cur); } widgets::hairline(&mut ru); @@ -797,7 +797,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let opts: Vec<(String, &str)> = options.iter().cloned().zip(labels.iter().map(String::as_str)).collect(); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim)); - widgets::dropdown(ui, &format!("gallery-{}", p.key), &mut c, &opts, 140.0); + widgets::dropdown(ui, &format!("gallery-{}", p.key), &label, &mut c, &opts, 140.0); }); e.params.insert(p.key, json!(c)); } diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index d99db8c25e..3b67b0bf68 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -990,15 +990,15 @@ fn device_limits_for(backend: wgpu::Backend, supported: wgpu::Limits) -> wgpu::L wgpu::Limits { max_texture_dimension_2d: supported.max_texture_dimension_2d, max_buffer_size: supported.max_buffer_size, ..base } } -/// Milliseconds since first call (monotonic). Always 0 on wasm, where `std::time` is unavailable. +/// Milliseconds since the process (or page) started, from a monotonic clock. +/// +/// `web_time::Instant` is `std::time::Instant` natively and `performance.now()` on wasm, so frame +/// caps, autosave intervals, preference retry backoff, stroke traces and screenshot deadlines all +/// measure durations the same way on both. The old wasm value was a constant 0, which made every +/// interval comparison (`now >= deadline`) permanently false on the web. pub fn now_ms() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - static START: std::sync::OnceLock = std::sync::OnceLock::new(); - START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 - } - #[cfg(target_arch = "wasm32")] - 0.0 + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() * 1000.0 } /// UI-thread timings (CPU side), exponentially smoothed. diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index e00c384790..9ddd08bee1 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -263,7 +263,7 @@ pub fn show_fallback(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = crate::theme::Tokens::get(ctx); let mut choice = None; egui::Window::new(tl!("Switched to CPU rendering")) - .id(egui::Id::new("gpu-fallback-warning")) + .id(egui::Id::unique("gpu-fallback-warning")) .collapsible(false) .resizable(false) .default_width(440.0) diff --git a/crates/ui-egui/src/gradient_ui.rs b/crates/ui-egui/src/gradient_ui.rs index 5265c86066..37c06f4fa1 100644 --- a/crates/ui-egui/src/gradient_ui.rs +++ b/crates/ui-egui/src/gradient_ui.rs @@ -565,7 +565,7 @@ pub fn paint_thumbnail(ui: &egui::Ui, layer: LayerId, f: &Fill, rect: Rect) -> b return false; } let ctx = ui.ctx(); - let key = egui::Id::new(("gradient-thumb", layer.0)); + let key = egui::Id::unique(("gradient-thumb", layer.0)); let cached: Option<(Fill, egui::TextureHandle)> = ctx.data(|d| d.get_temp(key)); let tex = match cached { Some((cf, t)) if cf == *f => t, @@ -596,7 +596,7 @@ pub fn preset_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { paint_ramp(ui.painter(), r, |u| photocraft_algo::paint::sample_stops(&stops, u)); ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); // Name the control for accessibility (and so tests and agents can find it). - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Gradient picker"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Gradient picker"))); resp.on_hover_text(tl!("Click to edit the gradient")).clicked() } @@ -622,7 +622,7 @@ pub fn editor_window(app: &mut PhotocraftApp, ctx: &egui::Context) { // Resolved before the closure so `app` is borrowed only once inside it. let layer = selected_gradient(app); egui::Window::new(tl!("Gradient Editor")) - .id(egui::Id::new("gradient-editor")) + .id(egui::Id::unique("gradient-editor")) .open(&mut open) .collapsible(false) .resizable(true) @@ -745,8 +745,8 @@ impl Sink { /// Uniqueness for the drag state. fn key(self) -> egui::Id { match self { - Sink::Layer(id) => egui::Id::new(("gradient-stops-drag", id.0)), - Sink::Preset => egui::Id::new(("gradient-stops-drag", "preset")), + Sink::Layer(id) => egui::Id::unique(("gradient-stops-drag", id.0)), + Sink::Preset => egui::Id::unique(("gradient-stops-drag", "preset")), } } fn cmd(self) -> &'static str { @@ -887,7 +887,7 @@ fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pendi let w = ui.available_width().max(120.0); let (outer, resp) = ui.allocate_exact_size(vec2(w, 58.0), Sense::click_and_drag()); // Name the strip for accessibility (and so tests and agents can find it). - 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 top = outer.top() + 16.0; let bar = Rect::from_min_max(pos2(outer.left() + 8.0, top), pos2(outer.right() - 8.0, top + 24.0)); let x_of = |loc: f32| bar.left() + loc.clamp(0.0, 1.0) * bar.width(); diff --git a/crates/ui-egui/src/gradient_ui/properties_panel.rs b/crates/ui-egui/src/gradient_ui/properties_panel.rs index d975102539..271118de40 100644 --- a/crates/ui-egui/src/gradient_ui/properties_panel.rs +++ b/crates/ui-egui/src/gradient_ui/properties_panel.rs @@ -87,7 +87,7 @@ fn numeric_row(ui: &mut egui::Ui, id: &str, name: &str, current: f32, range: std ui.push_id(id, |ui| { ui.horizontal(|ui| { let label = row_label(ui, name); - let key = ui.id().with("draft"); + let key = ui.scope_id().with("draft"); let mut value = ui.data(|d| d.get_temp::(key)).unwrap_or(current); let response = widgets::value_field(ui, &mut value, range, suffix, ui.available_width().min(88.0)).labelled_by(label.id); if response.dragged() || response.has_focus() { @@ -130,7 +130,7 @@ fn style_button(ui: &mut egui::Ui, style: GradientStyle, selected: bool, name: & } ui.painter().add(egui::Shape::mesh(mesh)); ui.painter().rect_stroke(preview, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Outside); - response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), selected, tl!(name))); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, tl!(name))); response.on_hover_text(tl!(name)) } @@ -155,7 +155,7 @@ pub(super) fn stop_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink let rgb = color.to_rgb(); ui.painter().rect_filled(rect.shrink(2.0), Tokens::get(ui.ctx()).radius_sm, c32([rgb[0], rgb[1], rgb[2], 1.0])); ui.painter().rect_stroke(rect, Tokens::get(ui.ctx()).radius_sm, Stroke::new(1.0, Tokens::get(ui.ctx()).field_border), StrokeKind::Inside); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Color"))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Color"))); if response.on_hover_text(tl!("Color")).clicked() { match sink { Sink::Layer(id) => edit_stop_color(app, id, i), @@ -202,7 +202,7 @@ pub(super) fn stop_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink ui.add_space(72.0 + ui.spacing().item_spacing.x); ui.add_enabled_ui(count > 2, |ui| { let response = icons::button(ui, "trash", 24.0, false, "Delete"); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Delete"))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Delete"))); if response.clicked() { let _ = app.run(sink.cmd(), sink.params(json!({"action": "delete", "kind": kind, "index": index}))); ui.data_mut(|d| d.remove::(key)); diff --git a/crates/ui-egui/src/guide_layout_ui.rs b/crates/ui-egui/src/guide_layout_ui.rs index 894535e687..f44be59a84 100644 --- a/crates/ui-egui/src/guide_layout_ui.rs +++ b/crates/ui-egui/src/guide_layout_ui.rs @@ -190,9 +190,10 @@ fn check(ui: &mut egui::Ui, f: &mut Map, key: &str, label: &str, value } -fn number(ui: &mut egui::Ui, f: &mut Map, key: &str, count: bool) { +fn number(ui: &mut egui::Ui, f: &mut Map, key: &str, name: &str, count: bool) { let mut value = f.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32; if widgets::value_field(ui, &mut value, (if count { 1.0 } else { 0.0 })..=if count { 1000.0 } else { 300_000.0 }, if count { "" } else { "px" }, 68.0) + .accessible_name(tl!(name)) .changed() // A disabled field still clamps its value into range (a zero count to 1); writing that // back would switch the axis on through its `positive` fallback (#5625). @@ -202,14 +203,16 @@ fn number(ui: &mut egui::Ui, f: &mut Map, key: &str, count: bool) } } -fn optional(ui: &mut egui::Ui, f: &mut Map, key: &str) { +fn optional(ui: &mut egui::Ui, f: &mut Map, key: &str, label: egui::Id) { let mut value = match f.get(key) { Some(Value::String(s)) => s.clone(), Some(v) if v.is_number() => v.to_string(), _ => String::new(), }; let t = Tokens::get(ui.ctx()); - let response = ui.add(egui::TextEdit::singleline(&mut value).id_salt(key).desired_width(60.0).font(crate::theme::mono(12.0)).background_color(t.field)); + let response = ui + .add(egui::TextEdit::singleline(&mut value).id_salt(key).desired_width(60.0).font(crate::theme::mono(12.0)).background_color(t.field)) + .labelled_by(label); if ui.is_enabled() { crate::field_tab::register(ui.ctx(), response.id); } @@ -242,13 +245,13 @@ fn axis(ui: &mut egui::Ui, f: &mut Map, rows: bool) { ui.add_enabled_ui(enabled, |ui| { egui::Grid::new(count).spacing([8.0, 6.0]).show(ui, |ui| { ui.label(tl!("Number")); - number(ui, f, count, true); + number(ui, f, count, "Number", true); ui.end_row(); - ui.label(size_label); - optional(ui, f, size); + let size_label_resp = ui.label(size_label); + optional(ui, f, size, size_label_resp.id); ui.end_row(); ui.label(tl!("Spacing")); - number(ui, f, gap, false); + number(ui, f, gap, "Spacing", false); ui.end_row(); }); }); @@ -263,6 +266,7 @@ fn main_fields(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map bool { } fn latch_id() -> egui::Id { - egui::Id::new("pc-temporary-tool") + egui::Id::unique("pc-temporary-tool") } /// The temporary tool for this canvas frame: the held one, except while a selection is being diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e12ed4f59e..e87c1024a1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4166,3 +4166,6 @@ cameraRaw Dehaze Odstranění oparu Grayscale Ramp Přechod stupňů šedi Current Colors Aktuální barvy Make Ramp Web Safe Omezit pruh na webové barvy + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Aspect Ratio Poměr stran diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bf290176a8..f0be98bc8b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3433,3 +3433,12 @@ curves Smooth Glätten Grayscale Ramp Graustufenbalken Current Colors Aktuelle Farben Make Ramp Web Safe Farbbalken auf Webfarben beschränken + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Aspect Ratio Seitenverhältnis + Category Kategorie + Color Table Farbtabelle + Gamma Gamma + Interpolation Interpolation + Target Ziel + Units Einheiten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6db59bc44a..be4ccdb302 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4120,3 +4120,6 @@ curves Smooth Εξομάλυνση Grayscale Ramp Διαβάθμιση του γκρι Current Colors Τρέχοντα χρώματα Make Ramp Web Safe Μόνο χρώματα ιστού στη διαβάθμιση + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Target Στόχος diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 34d749f777..de40f1ae32 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -535,3 +535,14 @@ cameraRaw Yellow पीला Missing delete command हटाने का कमांड नहीं मिला Invalid delete command हटाने का कमांड अमान्य है Layer deleted, but confirmation preference could not be saved: {error} लेयर हटा दी गई, लेकिन पुष्टि की प्राथमिकता सहेजी नहीं जा सकी: {error} + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Aspect Ratio पहलू अनुपात + Category श्रेणी + Color Table रंग तालिका + Gamma गामा + Interpolation इंटरपोलेशन + Profile प्रोफ़ाइल + Target लक्ष्य + Units इकाइयाँ + Demosaic डिमोज़ेक diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 05b77f764e..4d545cf8d7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3435,3 +3435,9 @@ curves Smooth Haluskan Grayscale Ramp Gradasi Grayscale Current Colors Warna Saat Ini Make Ramp Web Safe Jadikan Gradasi Aman untuk Web + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Color Table Tabel Warna + Gamma Gamma + Profile Profil + Target Target diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3a4dc62a46..ca651da456 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3631,3 +3631,9 @@ curves Smooth 매끄럽게 Grayscale Ramp 회색 음영 램프 Current Colors 현재 색상 Make Ramp Web Safe 램프를 웹 안전 색상으로 만들기 + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Aspect Ratio 종횡비 + Category 카테고리 + Target 대상 + Units 단위 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 22b9ff60c4..38f378a7b7 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -409,7 +409,7 @@ pub fn language_scope(lang: Lang) -> impl Drop { pub fn sync_context(ctx: &egui::Context, language: &str) { let lang = Lang::from_pref(language); set_current(lang); - let id = egui::Id::new("photocraft-ui-language"); + let id = egui::Id::unique("photocraft-ui-language"); let changed = ctx.data(|data| data.get_temp::(id) != Some(lang)); if changed { ctx.data_mut(|data| data.insert_temp(id, lang)); diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f6334bbc51..f3dfe75fb2 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4037,3 +4037,6 @@ curves Smooth Wygładź Grayscale Ramp Pasek skali szarości Current Colors Bieżące kolory Make Ramp Web Safe Ogranicz pasek do kolorów internetowych + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Target Cel diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 82540b4754..7b699f8b2d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4130,3 +4130,6 @@ curves Smooth Згладити Grayscale Ramp Шкала градацій сірого Current Colors Поточні кольори Make Ramp Web Safe Зробити шкалу безпечною для вебу + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Target Ціль diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ddef986df6..04c33672cb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3913,3 +3913,6 @@ curves Smooth Làm mượt Grayscale Ramp Dải thang xám Current Colors Màu hiện tại Make Ramp Web Safe Giới hạn dải màu ở màu web + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Aspect Ratio Tỷ lệ khung hình diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d08bb7acd2..7a93749027 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3089,3 +3089,13 @@ curves Smooth 平滑 Exported {path} 已导出 {path} Exported {n} files 已导出 {n} 个文件 Driver: {driver} 驱动程序:{driver} + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Aspect Ratio 长宽比 + Category 类别 + Color Table 颜色表 + Gamma 伽马 + Interpolation 插值 + Profile 配置文件 + Target 目标 + Units 单位 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index dd05b6ad5b..5ab79e80d4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3045,3 +3045,13 @@ curves Smooth 平滑 Grayscale Ramp 灰階色帶 Current Colors 目前色彩 Make Ramp Web Safe 讓色帶成為網頁安全色 + +# Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) + Aspect Ratio 外觀比例 + Category 類別 + Color Table 色彩表 + Gamma Gamma + Interpolation 內插 + Profile 描述檔 + Target 目標 + Units 單位 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index bb1bba202b..f5f799a5df 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -39,7 +39,7 @@ pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) { /// cursor (`CursorIcon::None`). pub fn cursor(ctx: &egui::Context, name: &str, p: egui::Pos2, hot: Vec2, size: f32) { let rect = Rect::from_min_size(p - hot * size, Vec2::splat(size)); - let area = egui::Area::new(egui::Id::new("pc-icon-cursor")).order(egui::Order::Tooltip).fixed_pos(rect.min).constrain(false).interactable(false); + let area = egui::Area::new(egui::Id::unique("pc-icon-cursor")).order(egui::Order::Tooltip).fixed_pos(rect.min).constrain(false).interactable(false); area.show(ctx, |ui| { let outline = image(name, size, Color32::from_black_alpha(200)); for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0)] { @@ -64,7 +64,7 @@ pub fn cursor_copy_badge(ctx: &egui::Context, p: egui::Pos2) { fn badge(ctx: &egui::Context, name: &str, p: egui::Pos2, doubled: bool) { const SIZE: f32 = 12.0; let rect = Rect::from_center_size(p + egui::vec2(20.0, 19.0), Vec2::splat(SIZE)); - let area = egui::Area::new(egui::Id::new("pc-cursor-badge")).order(egui::Order::Tooltip).fixed_pos(rect.min).constrain(false).interactable(false); + let area = egui::Area::new(egui::Id::unique("pc-cursor-badge")).order(egui::Order::Tooltip).fixed_pos(rect.min).constrain(false).interactable(false); area.show(ctx, |ui| { let outline = image(name, SIZE, Color32::from_black_alpha(220)); let halo = [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0)]; @@ -211,7 +211,7 @@ pub fn rail_button(ui: &mut egui::Ui, name: &str, box_size: f32, on: bool, toolt } let tint = if on || resp.hovered() { t.text } else { t.text_faint }; paint(ui, rect, name, (box_size * 0.52).round(), tint); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), on, tl!(tooltip))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, tl!(tooltip))); resp.on_hover_text(tl!(tooltip)) } diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 89dc5e5483..e55d18849b 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -166,7 +166,13 @@ fn clicking_outside_a_dialog_keeps_it_open_and_the_canvas_pans_and_zooms() { // Scrolling over the canvas pans it. let c0 = h.state().ui.views[0].center; - h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -40.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }); + h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -40.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); h.run_steps(8); let c1 = h.state().ui.views[0].center; assert!(c1[1] > c0[1], "scroll pans under a dialog: {c0:?} -> {c1:?}"); @@ -421,7 +427,16 @@ fn command_click_under_curves_adds_one_point_per_press_not_per_frame() { /// with ⌘/Ctrl), one history step per press; ⌘/Ctrl+←/→ moves by word; Alt+Shift+→ extends /// the selection by a word. fn wheel(h: &Harness<'static, PhotocraftApp>, dy: f32, modifiers: Modifiers) { - h.event_modifiers(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers }, modifiers); + h.event_modifiers( + egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers, + }, + modifiers, + ); } /// The document point under screen point `p` (no flip). diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 980aff20eb..fabb7836a8 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -744,7 +744,7 @@ pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let mut cancel_it = false; // Photoshop doesn't dim the window behind its progress dialog either. - let modal = egui::Modal::new(egui::Id::new("job-progress")).backdrop_color(Color32::from_black_alpha(36)).show(ctx, |ui| { + let modal = egui::Modal::new(egui::Id::unique("job-progress")).backdrop_color(Color32::from_black_alpha(36)).show(ctx, |ui| { ui.set_width(360.0); ui.label(RichText::new(tl!(&j.label)).font(crate::theme::semibold(15.0))); ui.add_space(4.0); @@ -789,7 +789,7 @@ pub struct OpenCardRects { } fn open_card_rects_id() -> egui::Id { - egui::Id::new("open-card-rects") + egui::Id::unique("open-card-rects") } /// The opening card's parts as drawn last frame, if it was shown. @@ -813,7 +813,7 @@ pub fn open_card(app: &mut PhotocraftApp, ui: &mut egui::Ui, job: JobId) { ui.painter().rect_filled(area, 0.0, t.canvas); // Centre the card at the height its content needed last frame; the background is painted // once the rows are laid out, so a taller content (a long translation) still gets a card. - let height_id = egui::Id::new(("open-card-height", job)); + let height_id = egui::Id::unique(("open-card-height", job)); let height = ui.ctx().data(|d| d.get_temp::(height_id)).unwrap_or(OPEN_CARD_MIN_HEIGHT); let card = Rect::from_center_size(area.center(), vec2(OPEN_CARD_WIDTH, height)); let fill = ui.painter().add(egui::Shape::Noop); diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index b9ff0f00a5..6b58786a85 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -483,9 +483,13 @@ fn save_in_background_off_blocks_the_app_until_the_file_is_written() { assert_eq!(r["pending"], true, "the same background job does the work"); h.run_steps(1); // The dialog is up from the first frame (no delay), titled with the save, then its stage. + // A new modal's accessibility tree can take a second pass to appear under kittest. + let t = Instant::now(); + while h.query_by_label("Saving fg.psd").is_none() && t.elapsed() < Duration::from_secs(30) { + h.step(); + } h.get_by_label("Saving fg.psd"); h.get_by_label("Esc to cancel"); - let t = Instant::now(); while h.query_by_label("Encoding").is_none() && t.elapsed() < Duration::from_secs(30) { h.step(); } diff --git a/crates/ui-egui/src/jpeg_options_ui.rs b/crates/ui-egui/src/jpeg_options_ui.rs index 3066efe9b2..025bcd2b9e 100644 --- a/crates/ui-egui/src/jpeg_options_ui.rs +++ b/crates/ui-egui/src/jpeg_options_ui.rs @@ -196,7 +196,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(p) = &mut app.jpeg_options else { return }; let (mut ok, mut cancel) = (false, false); let (mut quality, mut format) = (p.quality, p.format); - let modal = egui::Modal::new(egui::Id::new("jpeg-options")).show(ctx, |ui| { + let modal = egui::Modal::new(egui::Id::unique("jpeg-options")).show(ctx, |ui| { ui.set_max_width(360.0); ui.label(egui::RichText::new(tl!("JPEG Options")).font(crate::theme::semibold(15.0))); ui.add_space(4.0); @@ -204,18 +204,19 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.add_space(8.0); crate::widgets::section_label(ui, tl!("Image Options")); ui.horizontal(|ui| { - ui.label(tl!("Quality")); - ui.add(egui::DragValue::new(&mut quality).range(0..=MAX_QUALITY).speed(0.1)); + let quality_caption = ui.label(tl!("Quality")); + ui.add(egui::DragValue::new(&mut quality).range(0..=MAX_QUALITY).speed(0.1)).labelled_by(quality_caption.id); // Photoshop's presets: picking one sets the quality it starts at. - egui::ComboBox::from_id_salt("jpeg-quality-preset").selected_text(quality_label(quality)).show_ui(ui, |ui| { + let preset = egui::ComboBox::from_id_salt("jpeg-quality-preset").selected_text(quality_label(quality)).show_ui(ui, |ui| { for q in [3, 5, 8, 10] { if ui.selectable_label(quality_label(quality) == quality_label(q), quality_label(q)).clicked() { quality = q; } } }); + let _ = preset.response.accessible_name(tl!("Preset")); }); - ui.add(egui::Slider::new(&mut quality, 0..=MAX_QUALITY).show_value(false)); + ui.add(egui::Slider::new(&mut quality, 0..=MAX_QUALITY).show_value(false)).accessible_name(tl!("Quality")); ui.add_space(8.0); crate::widgets::section_label(ui, tl!("Format Options")); ui.radio_value(&mut format, JpegFormat::Baseline, tl!("Baseline (\"Standard\")")); diff --git a/crates/ui-egui/src/kys_import.rs b/crates/ui-egui/src/kys_import.rs index 75f46a775b..c58499620f 100644 --- a/crates/ui-egui/src/kys_import.rs +++ b/crates/ui-egui/src/kys_import.rs @@ -222,7 +222,7 @@ pub fn show_offer(app: &mut PhotocraftApp, ctx: &egui::Context) { ); let mut answer = None; egui::Window::new(tl!("Import keyboard shortcuts?")) - .id(egui::Id::new("kys-offer")) + .id(egui::Id::unique("kys-offer")) .collapsible(false) .resizable(false) .default_width(440.0) diff --git a/crates/ui-egui/src/layer_filter_ui.rs b/crates/ui-egui/src/layer_filter_ui.rs index e933d0c165..82c17e650e 100644 --- a/crates/ui-egui/src/layer_filter_ui.rs +++ b/crates/ui-egui/src/layer_filter_ui.rs @@ -123,7 +123,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let by = [(FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), (FilterBy::Mode, tl!("Mode"))]; - widgets::dropdown(ui, "layer-filter-by", &mut app.ui.layer_filter_by, &by, 64.0); + widgets::dropdown(ui, "layer-filter-by", tl!("Kind"), &mut app.ui.layer_filter_by, &by, 64.0); ui.add_space(4.0); match app.ui.layer_filter_by { FilterBy::Kind => { @@ -146,12 +146,12 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } FilterBy::Name => { let width = ui.available_width().max(48.0); - widgets::text_field(ui, ui.id().with("layer-filter-name"), &mut app.ui.layer_filter_name, tl!("Search"), width, false); + widgets::text_field(ui, ui.scope_id().with("layer-filter-name"), &mut app.ui.layer_filter_name, tl!("Search"), width, false); } FilterBy::Mode => { let modes: Vec<(BlendMode, &str)> = std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect(); let width = ui.available_width().max(96.0); - widgets::dropdown(ui, "layer-filter-mode", &mut app.ui.layer_filter_mode, &modes, width); + widgets::dropdown(ui, "layer-filter-mode", tl!("Mode"), &mut app.ui.layer_filter_mode, &modes, width); } } }); diff --git a/crates/ui-egui/src/layer_menu_ui.rs b/crates/ui-egui/src/layer_menu_ui.rs index a26b2f1b32..0ea4db41ea 100644 --- a/crates/ui-egui/src/layer_menu_ui.rs +++ b/crates/ui-egui/src/layer_menu_ui.rs @@ -289,8 +289,8 @@ pub(crate) fn color_choices(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &L fn color_button(ui: &mut egui::Ui, color: LabelColor, checked: bool) -> egui::Response { use egui::{Atom, Rect, Stroke, StrokeKind, vec2}; let t = crate::theme::Tokens::get(ui.ctx()); - let check_id = ui.id().with(("label-check", color.id())); - let swatch_id = ui.id().with(("label-swatch", color.id())); + let check_id = egui::IdSalt::new(("label-check", color.id())); + let swatch_id = egui::IdSalt::new(("label-swatch", color.id())); let name = color_name(color); let button = egui::Button::new((Atom::custom(check_id, vec2(14.0, 16.0)), Atom::custom(swatch_id, vec2(16.0, 16.0)), name, Atom::grow())).atom_ui(ui); if checked && let Some(rect) = button.rect(check_id) { @@ -309,7 +309,7 @@ fn color_button(ui: &mut egui::Ui, color: LabelColor, checked: bool) -> egui::Re p.line_segment([r.right_top(), r.left_bottom()], stroke); } } - button.response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), checked, name)); + button.response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), checked, name)); button.response } @@ -553,7 +553,7 @@ mod tests { let ctx = egui::Context::default(); crate::PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 300.0)); - let id = egui::Id::new("layer-menu-probe"); + let id = egui::Id::unique("layer-menu-probe"); let mut content = 0.0; for _ in 0..4 { let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; diff --git a/crates/ui-egui/src/layer_menu_ui/color_tests.rs b/crates/ui-egui/src/layer_menu_ui/color_tests.rs index 88c11dfd2c..4bc114ae5f 100644 --- a/crates/ui-egui/src/layer_menu_ui/color_tests.rs +++ b/crates/ui-egui/src/layer_menu_ui/color_tests.rs @@ -41,9 +41,9 @@ fn layer_color_context_menu_edits_the_clicked_layer_and_preserves_multi_selectio let (mut h, red, other) = harness(crate::theme::ThemeKind::Pro, ppp); let at = row(&h, red).center(); click(&mut h, at, PointerButton::Secondary, Modifiers::NONE); - let at = h.get_by_label("Color ⏵").rect().center(); + let at = h.query_all_by_role_and_label(egui::accesskit::Role::Button, "Color").last().expect("the Color submenu").rect().center(); click(&mut h, at, PointerButton::Primary, Modifiers::NONE); - let at = h.get_by_label("Seafoam").rect().center(); + let at = h.get_by_role_and_label(egui::accesskit::Role::Button, "Seafoam").rect().center(); click(&mut h, at, PointerButton::Primary, Modifiers::NONE); let st = h.state().session.active().unwrap(); assert_eq!(st.active_layer, Some(red)); @@ -55,9 +55,9 @@ fn layer_color_context_menu_edits_the_clicked_layer_and_preserves_multi_selectio assert_eq!(common_color(h.state(), clicked, true), None); let at = row(&h, red).center(); click(&mut h, at, PointerButton::Secondary, Modifiers::NONE); - let at = h.get_by_label("Color ⏵").rect().center(); + let at = h.query_all_by_role_and_label(egui::accesskit::Role::Button, "Color").last().expect("the Color submenu").rect().center(); click(&mut h, at, PointerButton::Primary, Modifiers::NONE); - let at = h.get_by_label("Indigo").rect().center(); + let at = h.get_by_role_and_label(egui::accesskit::Role::Button, "Indigo").rect().center(); click(&mut h, at, PointerButton::Primary, Modifiers::NONE); let st = h.state().session.active().unwrap(); assert_eq!(st.selected_layers().len(), 2); @@ -119,7 +119,7 @@ fn right_clicking_eye_opens_color_choices_without_toggling_visibility() { let r = row(&h, red); let eye = pos2(r.left() + 17.0, r.center().y); click(&mut h, eye, PointerButton::Secondary, Modifiers::NONE); - let blue = h.get_by_label("Blue").rect().center(); + let blue = h.get_by_role_and_label(egui::accesskit::Role::Button, "Blue").rect().center(); click(&mut h, blue, PointerButton::Primary, Modifiers::NONE); let st = h.state().session.active().unwrap(); @@ -136,6 +136,6 @@ fn layer_color_menu_marks_only_the_common_color() { } }); h.run(); - assert_eq!(h.get_by_label("Blue").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); - assert_eq!(h.get_by_label("Red").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::Button, "Blue").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::Button, "Red").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); } diff --git a/crates/ui-egui/src/layer_pick_ui.rs b/crates/ui-egui/src/layer_pick_ui.rs index b75fe2b47b..3a3bf1efa1 100644 --- a/crates/ui-egui/src/layer_pick_ui.rs +++ b/crates/ui-egui/src/layer_pick_ui.rs @@ -62,7 +62,7 @@ pub fn choose(app: &mut PhotocraftApp, layer: u64) { } fn menu_id() -> egui::Id { - egui::Id::new("canvas-layer-menu") + egui::Id::unique("canvas-layer-menu") } /// Show the open menu. Escape or a click outside closes it. diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index 86de0f363c..778cb8f6cd 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -48,13 +48,15 @@ fn edit_tolerance(layer: &Layer, u: LenUnit, extent: f64) -> f32 { } } -/// `len` is the unit shown and its 100% extent (for percent). -fn field(ui: &mut egui::Ui, id: &str, label: (&str, &str), current: f32, width: f32, len: (LenUnit, f64), tol: f32) -> Option { +/// `len` is the unit shown and its 100% extent (for percent); `name` is the field's accessible name. +// The explicit accessible name accompanies the existing numeric-field parameters. +#[expect(clippy::too_many_arguments)] +fn field(ui: &mut egui::Ui, id: &str, label: (&str, &str), name: &str, current: f32, width: f32, len: (LenUnit, f64), tol: f32) -> Option { let (u, extent) = len; stacked_label(ui, tl!(label.0), &[tl!(label.0), tl!(label.1)], LABEL_W, 2.0); let key = ui.make_persistent_id(("layer-props-field", id)); let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(f64::from(current), extent)); - let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, u.unit.suffix(), width); + let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, u.unit.suffix(), width).accessible_name(tl!(name)); if resp.dragged() || resp.has_focus() { ui.data_mut(|d| d.insert_temp(key, v)); return None; @@ -156,30 +158,30 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let (dw, dh) = app.session.active().map_or((1.0, 1.0), |s| (f64::from(s.doc.size.width), f64::from(s.doc.size.height))); let (tw, th) = (edit_tolerance(layer, u, dw), edit_tolerance(layer, u, dh)); if section(ui, "transform", tl!("Transform")) { - let link_key = egui::Id::new("layer-props-link"); + let link_key = egui::Id::unique("layer-props-link"); let linked = ui.data(|d| d.get_temp::(link_key)).unwrap_or(true); // Two label+field columns with the link toggle between them, filling the panel. let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "w", ("W", "X"), (bb[2] - bb[0]) as f32, w, (u, dw), tw) { + if let Some(v) = field(ui, "w", ("W", "X"), "Width", (bb[2] - bb[0]) as f32, w, (u, dw), tw) { run.extend(transform_params(layer.id.0, bb, Some(v), None, linked, tw).map(|p| ("edit.transform".to_string(), p))); } if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, tl!("Link width and height")).clicked() { ui.data_mut(|d| d.insert_temp(link_key, !linked)); } - if let Some(v) = field(ui, "h", ("H", "Y"), (bb[3] - bb[1]) as f32, w, (u, dh), th) { + if let Some(v) = field(ui, "h", ("H", "Y"), "Height", (bb[3] - bb[1]) as f32, w, (u, dh), th) { run.extend(transform_params(layer.id.0, bb, None, Some(v), linked, th).map(|p| ("edit.transform".to_string(), p))); } }); ui.add_space(ROW_GAP); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = LABEL_GAP; - if let Some(v) = field(ui, "x", ("X", "W"), bb[0] as f32, w, (u, dw), 0.5) { + if let Some(v) = field(ui, "x", ("X", "W"), "X", bb[0] as f32, w, (u, dw), 0.5) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - bb[0] as f32).round() as i32, "dy": 0}))); } ui.add_space(LINK_W + LABEL_GAP); - if let Some(v) = field(ui, "y", ("Y", "H"), bb[1] as f32, w, (u, dh), 0.5) { + if let Some(v) = field(ui, "y", ("Y", "H"), "Y", bb[1] as f32, w, (u, dh), 0.5) { run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - bb[1] as f32).round() as i32}))); } }); @@ -241,7 +243,7 @@ fn align_and_distribute(app: &PhotocraftApp, ui: &mut egui::Ui, run: &mut Vec<(S } align_glyph(ui, r, kind, if on { t.icon } else { t.text_faint }); let tip = crate::i18n::fmt(tl!("Align {what}"), &[("what", tl!(&label))]); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, &tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, &tip)); if resp.on_hover_text(tip).clicked() { run.push((id, json!({}))); } diff --git a/crates/ui-egui/src/layer_reveal.rs b/crates/ui-egui/src/layer_reveal.rs index bf80bba64d..14ee2a36b3 100644 --- a/crates/ui-egui/src/layer_reveal.rs +++ b/crates/ui-egui/src/layer_reveal.rs @@ -17,7 +17,7 @@ use crate::PhotocraftApp; struct Seen(usize, Option); fn seen_id() -> egui::Id { - egui::Id::new("layers-reveal-seen") + egui::Id::unique("layers-reveal-seen") } /// Called once per Layers panel frame, before its rows are listed. When the active layer diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index 0658a797bc..0aa8341c4f 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -190,7 +190,7 @@ pub fn indicators( icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "triangle-alert", 12.0, t.warning); let key = unsupported.unwrap_or_default(); let tip = crate::i18n::fmt(tl!("Not rendered: this adjustment ({key}) is kept as imported."), &[("key", key)]); - ui.interact(r, ui.id().with(("row-unsupported", l.id.0)), Sense::hover()).on_hover_text(tip); + ui.interact(r, ui.scope_id().with(("row-unsupported", l.id.0)), Sense::hover()).on_hover_text(tip); } Indicator::Lock => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "lock", 12.0, t.text_faint), Indicator::Link => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "link-2", 12.0, t.text_faint), @@ -207,10 +207,10 @@ pub fn indicators( Indicator::SmartFilters => { let enabled = matches!(&l.content, photocraft_doc::LayerContent::Smart(sm) if sm.filters_enabled); crate::smart_ui::paint_badge(painter, r.center(), if enabled { t.icon } else { t.text_faint }); - ui.interact(r, ui.id().with(("smart-filters-badge", l.id.0)), Sense::hover()).on_hover_text(tl!("Smart Filters")); + ui.interact(r, ui.scope_id().with(("smart-filters-badge", l.id.0)), Sense::hover()).on_hover_text(tl!("Smart Filters")); } Indicator::FxTriangle => { - let resp = ui.interact(r, ui.id().with(("fx-disclosure", l.id.0)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("fx-disclosure", l.id.0)), Sense::click()); paint_triangle(painter, r.center(), fx_open, if resp.hovered() { t.text } else { t.icon }); if resp.clicked() { clicked = true; @@ -228,7 +228,7 @@ pub fn indicators( tl!("Show the layer's effects ({key}-click: all layers)") }; let resp = resp.on_hover_text(crate::i18n::fmt(tip, &[("key", &crate::shortcuts::pretty("Alt"))])); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{verb} effects {name}"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{verb} effects {name}"))); } } } @@ -328,7 +328,7 @@ pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) { } fn rects_id() -> egui::Id { - egui::Id::new("layer-row-rects") + egui::Id::unique("layer-row-rects") } /// Start a frame's record (the Layers panel calls it before drawing its rows). @@ -370,7 +370,7 @@ impl Rename { } fn rename_id() -> egui::Id { - egui::Id::new("layer-rename") + egui::Id::unique("layer-rename") } /// The rename in progress, if any. @@ -419,7 +419,7 @@ pub fn end_rename(ctx: &egui::Context, commit: bool) -> Option<(String, Value)> /// Where Tab or ⇧Tab sends a rename: the layer it left and whether the next one is above. fn next_rename_id() -> egui::Id { - egui::Id::new("layer-rename-next") + egui::Id::unique("layer-rename-next") } /// The rename that Tab (`up` false) or ⇧Tab (`up` true) just left on `layer`, for the Layers panel @@ -434,8 +434,8 @@ pub fn take_next_rename(ctx: &egui::Context) -> Option<(u64, bool)> { pub fn rename_field(ui: &mut egui::Ui, layer: u64, rect: Rect) -> Option<(String, Value)> { let ctx = ui.ctx().clone(); let mut r = rename(&ctx).filter(|r| r.layer == layer)?; - let id = egui::Id::new(("layer-rename-field", layer)); - let te = ui.put(rect, egui::TextEdit::singleline(&mut r.text).id(id).font(FontId::proportional(12.5))); + let id = egui::Id::unique(("layer-rename-field", layer)); + let te = ui.put(rect, egui::TextEdit::singleline(&mut r.text).id(id).font(FontId::proportional(12.5))).accessible_name(tl!("Name")); r.pass = ctx.cumulative_pass_nr(); if !r.focused { te.request_focus(); diff --git a/crates/ui-egui/src/layer_row_ui/rename_tests.rs b/crates/ui-egui/src/layer_row_ui/rename_tests.rs index 4a7ea135ff..2d65099365 100644 --- a/crates/ui-egui/src/layer_row_ui/rename_tests.rs +++ b/crates/ui-egui/src/layer_row_ui/rename_tests.rs @@ -84,7 +84,7 @@ fn type_text(h: &mut Harness<'_, PhotocraftApp>, text: &str) { /// Is `layer`'s rename field on screen (drawn last frame)? fn field_shown(h: &Harness<'_, PhotocraftApp>, layer: u64) -> bool { - h.ctx.read_response(egui::Id::new(("layer-rename-field", layer))).is_some() + h.ctx.read_response(egui::Id::unique(("layer-rename-field", layer))).is_some() } /// A point on the canvas, away from every panel. diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index b65487c4f7..9a802c90d7 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -889,7 +889,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (m.label().to_string(), m.label())).collect(); - if widgets::dropdown(ui, &format!("fx-blend-{sel_kind}"), &mut cur, &opts, 150.0) { + if widgets::dropdown(ui, &format!("fx-blend-{sel_kind}"), tl!("Blend Mode"), &mut cur, &opts, 150.0) { let value = json!(cur); disp[key] = value.clone(); set_param(f, &selected, key, value); @@ -1060,7 +1060,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = options.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - if widgets::dropdown(ui, &format!("fx-{sel_kind}-{key}"), &mut cur, &opts, 150.0) { + if widgets::dropdown(ui, &format!("fx-{sel_kind}-{key}"), tl!(&label), &mut cur, &opts, 150.0) { let value = json!(cur); disp[key] = value.clone(); set_param(f, &selected, key, value); @@ -1083,7 +1083,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = list.iter().map(|(id, n)| (id.clone(), n.as_str())).collect(); - if widgets::dropdown(ui, &format!("fx-{sel_kind}-{key}"), &mut cur, &opts, 180.0) + if widgets::dropdown(ui, &format!("fx-{sel_kind}-{key}"), tl!(&label), &mut cur, &opts, 180.0) || disp.get(key).and_then(Value::as_str).is_none_or(str::is_empty) { let value = json!(cur); @@ -1249,7 +1249,7 @@ fn blending_page(ui: &mut egui::Ui, p: &mut Value, names: &[String]) { // Photoshop's modes, plus the layer's own Paint.NET mode when it has one (an imported .pdn). let own = photocraft_color::BlendMode::PAINT_NET_MODES.into_iter().find(|m| m.label() == cur); let opts: Vec<(String, &str)> = photocraft_color::BlendMode::LAYER_MODES.into_iter().chain(own).map(|m| (m.label().to_string(), m.label())).collect(); - if widgets::dropdown(ui, "fx-blend-blendingOptions", &mut cur, &opts, 150.0) { + if widgets::dropdown(ui, "fx-blend-blendingOptions", tl!("Blend Mode"), &mut cur, &opts, 150.0) { p["blend"] = json!(cur); } }); @@ -1283,7 +1283,7 @@ fn blending_page(ui: &mut egui::Ui, p: &mut Value, names: &[String]) { ui.label(RichText::new(tl!("Knockout")).color(t.text_dim)); let mut cur = p.get("knockout").and_then(Value::as_str).unwrap_or("none").to_string(); let opts: Vec<(String, &str)> = KNOCKOUT.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - if widgets::dropdown(ui, "fx-blendingOptions-knockout", &mut cur, &opts, 150.0) { + if widgets::dropdown(ui, "fx-blendingOptions-knockout", tl!("Knockout"), &mut cur, &opts, 150.0) { p["knockout"] = json!(cur); } }); @@ -1316,7 +1316,7 @@ fn blending_page(ui: &mut egui::Ui, p: &mut Value, names: &[String]) { let labels: Vec = (0..entries).map(label).collect(); let opts: Vec<(usize, &str)> = labels.iter().enumerate().map(|(i, l)| (i, l.as_str())).collect(); let mut cur = sel; - if widgets::dropdown(ui, "fx-blendingOptions-blendIf", &mut cur, &opts, 110.0) { + if widgets::dropdown(ui, "fx-blendingOptions-blendIf", tl!("Blend If:"), &mut cur, &opts, 110.0) { p["blendIfChannel"] = json!(cur); } }); @@ -1329,7 +1329,7 @@ fn blending_page(ui: &mut egui::Ui, p: &mut Value, names: &[String]) { ui.label(RichText::new(format!("{} {}", side(q[0], q[1]), side(q[2], q[3]))).color(t.text)); }); }); - if blend_if_bar(ui, ui.id().with(("blendIf", key)), &mut q) + if blend_if_bar(ui, ui.scope_id().with(("blendIf", key)), &mut q) && let Some(e) = p.get_mut("blendIf").and_then(|a| a.get_mut(sel)) { e[key] = json!(q); @@ -2269,7 +2269,7 @@ mod tests { 120.0_f32, ); h.run(); - let dial = h.query_all_by_label("Angle").last().unwrap(); + let dial = h.get_by_role_and_label(egui::accesskit::Role::Slider, "Angle"); let rect = dial.rect(); for (delta, expected) in [(vec2(15.0, 0.0), 0.0), (vec2(0.0, -15.0), 90.0), (vec2(0.0, 15.0), -90.0)] { let pos = rect.center() + delta; diff --git a/crates/ui-egui/src/layer_style/contour.rs b/crates/ui-egui/src/layer_style/contour.rs index 295cf9f34c..0c1c18b605 100644 --- a/crates/ui-egui/src/layer_style/contour.rs +++ b/crates/ui-egui/src/layer_style/contour.rs @@ -330,7 +330,7 @@ pub(super) fn editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = None; @@ -350,7 +350,7 @@ pub(super) fn editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = CONTOURS.iter().map(|(n, _)| (n.to_string(), *n)).collect(); - if widgets::dropdown(ui, "contour-editor-preset", &mut cur, &opts, 190.0) { + if widgets::dropdown(ui, "contour-editor-preset", tl!("Preset"), &mut cur, &opts, 190.0) { preset = Some(cur); } }); @@ -405,7 +405,7 @@ pub(super) fn editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map egui::Id { - egui::Id::new("pc-layer-transfer") + egui::Id::unique("pc-layer-transfer") } fn get(ctx: &egui::Context) -> Option { @@ -99,7 +99,7 @@ pub fn over_tab(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) { app.session.set_active(index); app.jobs.focus = None; // The Layers panel now lists the other document: its rows don't take the drag. - ctx.data_mut(|d| d.remove::(egui::Id::new("layer-drag"))); + ctx.data_mut(|d| d.remove::(egui::Id::unique("layer-drag"))); } if ctx.input(|i| i.pointer.any_released()) { drop(app, ctx, index, None); @@ -168,7 +168,7 @@ fn drop(app: &mut PhotocraftApp, ctx: &egui::Context, dest: usize, at: Option<[f /// The dragged layers' name next to the pointer (also the Layers panel's reorder drag). pub fn ghost(ctx: &egui::Context, p: egui::Pos2, label: &str) { let t = crate::theme::Tokens::get(ctx); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("layer-transfer-ghost"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("layer-transfer-ghost"))); let g = painter.layout_no_wrap(label.to_string(), egui::FontId::proportional(12.0), t.text); let r = egui::Rect::from_min_size(p + egui::vec2(12.0, -10.0), g.size() + egui::vec2(16.0, 8.0)); painter.rect_filled(r, t.radius_sm, t.card.gamma_multiply(0.95)); diff --git a/crates/ui-egui/src/layer_tree_ui.rs b/crates/ui-egui/src/layer_tree_ui.rs index d00dffb8da..858fe432fc 100644 --- a/crates/ui-egui/src/layer_tree_ui.rs +++ b/crates/ui-egui/src/layer_tree_ui.rs @@ -56,7 +56,7 @@ pub fn triangle_rect(row: Rect, x: f32) -> Rect { pub fn disclosure(ui: &mut egui::Ui, row: Rect, x: &mut f32, l: &Layer, actions: &mut Vec<(String, Value)>) -> bool { let LayerContent::Group(g) = &l.content else { return false }; let hit = triangle_rect(row, *x); - let resp = ui.interact(hit, ui.id().with(("disclosure", l.id.0)), Sense::click()); + let resp = ui.interact(hit, ui.scope_id().with(("disclosure", l.id.0)), Sense::click()); let t = Tokens::get(ui.ctx()); let c = pos2(*x + TRIANGLE_W / 2.0 - 1.0, row.center().y); let s = 3.5; @@ -76,7 +76,7 @@ pub fn disclosure(ui: &mut egui::Ui, row: Rect, x: &mut f32, l: &Layer, actions: let (verb, name) = (if g.expanded { tl!("Collapse") } else { tl!("Expand") }, l.name.clone()); let tip = if g.expanded { tl!("Collapse group ({key}-click: all groups)") } else { tl!("Expand group ({key}-click: all groups)") }; let resp = resp.on_hover_text(crate::i18n::fmt(tip, &[("key", &crate::shortcuts::pretty("Alt"))])); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{verb} group {name}"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{verb} group {name}"))); clicked } diff --git a/crates/ui-egui/src/layers_panel_ui.rs b/crates/ui-egui/src/layers_panel_ui.rs index 5027b228b0..d640e3850d 100644 --- a/crates/ui-egui/src/layers_panel_ui.rs +++ b/crates/ui-egui/src/layers_panel_ui.rs @@ -260,7 +260,7 @@ pub fn show_dialog(app: &mut PhotocraftApp, ctx: &Context) { LayersPanelDialog::NewGroup(_) => tl!("New Group"), LayersPanelDialog::LockLayers { .. } => tl!("Lock Layers"), }; - egui::Modal::new(egui::Id::new("layers-panel-options-modal")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("layers-panel-options-modal")).show(ctx, |ui| { ui.set_width(if matches!(dialog, LayersPanelDialog::PanelOptions(_)) { 480.0 } else { 380.0 }); ui.horizontal(|ui| { ui.heading(title); diff --git a/crates/ui-egui/src/layout_keys.rs b/crates/ui-egui/src/layout_keys.rs index 28d2dc9c06..bd31428cca 100644 --- a/crates/ui-egui/src/layout_keys.rs +++ b/crates/ui-egui/src/layout_keys.rs @@ -68,7 +68,7 @@ fn is_digit(key: Key) -> bool { /// What was typed comes as the text event next to the key press: right after it natively, right /// before it on the web. pub fn apply(ctx: &egui::Context) { - let id = egui::Id::new("photocraft_layout_keys"); + let id = egui::Id::unique("photocraft_layout_keys"); let mut learnt: Learnt = ctx.data(|d| d.get_temp(id)).unwrap_or_default(); let before = (learnt.brackets, learnt.table_layout); ctx.input_mut(|i| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ef4ce35256..aa61b8ca73 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -194,6 +194,7 @@ mod rgb_histogram; pub mod rotate_view; pub mod rulers; pub mod screen_picker; +pub mod screenshot; pub mod scrollbars; mod scrub_wrap; pub mod select_mask_view; @@ -783,9 +784,11 @@ pub struct PhotocraftApp { /// The first digit of a two-digit opacity typed on the number keys (`opacity_keys`, #352). pub(crate) opacity_keys: opacity_keys::Pending, control_rx: Option>, - pending_screenshots: Vec<(u64, Option, Sender)>, - /// Screenshots not yet requested from the viewport: (token, earliest time in ms, frames seen). - queued_screenshots: Vec<(u64, f64, u32)>, + /// `ui.screenshot` requests: bounded admission, a weak-slot callback per issuance, monotonic + /// deadlines and a capture-time geometry epoch (see `screenshot.rs`). + captures: screenshot::Captures, + /// The custom Type tool's IME owner/generation/receipt tracker (`type_tool::sync_ime`). + pub(crate) type_ime: type_tool::ImeState, /// Control replies waiting for queued synthetic input to be processed. input_waiters: Vec>, /// Live (uncommitted) adjustment edit shown on canvas while a slider is dragged. @@ -862,6 +865,20 @@ pub struct PhotocraftApp { pub(crate) live_adjust_preview: Option, /// Synthetic input events queued by automation (`ui.click`, `ui.key`, …), injected next frame. pub(crate) synthetic: Vec, + /// The one synthetic batch currently being delivered. + /// + /// eframe preserves input appended by `raw_input_hook` across hidden (`update_logic_only`) + /// ticks, so draining another step every hidden tick would deliver several host iterations + /// as one burst. At most one batch is ever in flight; the next step waits for a pass. + synthetic_batch: Option>, + /// A pass consumed the batch but returned before handling input (fonts not ready): put the + /// same events back on the next hook so the step is not lost. Cleared as soon as they are + /// re-appended, so two hidden ticks cannot deliver the same step twice. + synthetic_reinject: bool, + /// Document/focus identity the batch was created for. A batch queued against one document is + /// cancelled, not replayed, after the active document (including a switch away and back) or + /// the focus changes; an edit in the same document does not cancel it. + synthetic_snapshot: Option, /// True only while a frame is processing synthetic automation input. It /// makes shortcut- and dialog-triggered commands pass the same policy as /// direct control calls. @@ -1018,6 +1035,26 @@ pub struct PhotocraftApp { live_tokens: theme::live::LiveTokens, } +/// The state a synthetic input batch was queued for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SyntheticTarget { + document: Option, + focus: Option, +} + +impl SyntheticTarget { + /// The document must be the same one in the same activation (a switch away and back is a + /// change); its revision is attribution only, so an edit does not cancel a queued step. + fn matches(self, other: Self) -> bool { + self.focus == other.focus + && match (self.document, other.document) { + (Some(a), Some(b)) => a.same_activation(b), + (None, None) => true, + _ => false, + } + } +} + impl PhotocraftApp { pub fn new(mut session: Session, mut services: Services) -> Self { session.updates.check = services.check_updates.take(); @@ -1066,8 +1103,8 @@ impl PhotocraftApp { caps_lock: false, opacity_keys: None, control_rx: None, - pending_screenshots: Vec::new(), - queued_screenshots: Vec::new(), + captures: screenshot::Captures::default(), + type_ime: type_tool::ImeState::default(), input_waiters: Vec::new(), live_adjust: None, frame: 0, @@ -1104,6 +1141,9 @@ impl PhotocraftApp { adjust_preview: None, live_adjust_preview: None, synthetic: Vec::new(), + synthetic_batch: None, + synthetic_reinject: false, + synthetic_snapshot: None, automation_input: false, paused_playbacks: Default::default(), channel_thumbs: None, @@ -2195,10 +2235,13 @@ impl PhotocraftApp { control::Outcome::AfterInput => self.input_waiters.push(reply), control::Outcome::AfterJob(job) => self.jobs.waiters.push((job, reply)), control::Outcome::Screenshot { token, path } => { - // Wait out egui's fade animations (~83 ms) and a few rendered frames first. - let settle = ctx.global_style().animation_time as f64 * 2000.0 + 60.0; - self.queued_screenshots.push((token, gpu_canvas::now_ms() + settle, 0)); - self.pending_screenshots.push((token, path, reply)); + // Admission is budgeted here, after this pass's completed captures were + // drained, so a just-finished request never causes a false rejection. + let settle = screenshot::settle_ms(ctx.global_style().animation_time); + let document = self.document_identity(); + if let Err((reply, e)) = self.captures.admit(token, path, reply, document, settle, screenshot::now()) { + let _ = reply.send(serde_json::json!({"ok": false, "error": e})); + } } } // `ui.set` may have changed the edit target: the next request sees its colours. @@ -2207,41 +2250,67 @@ impl PhotocraftApp { self.control_rx = Some(rx); } - fn issue_screenshots(&mut self, ctx: &egui::Context) { - let now = gpu_canvas::now_ms(); - self.queued_screenshots.retain_mut(|(token, at, frames)| { - *frames += 1; - if now >= *at && *frames >= 3 { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); - false - } else { - true - } - }); + /// The active document's identity at admission: a queued capture never applies to another + /// document. Includes the revision so a saved image is attributable to a known state. + fn document_identity(&self) -> Option { + self.session.active().map(|st| screenshot::DocumentId { document: st.doc.id.0, revision: st.revision, generation: self.session.active_generation() }) } - fn collect_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_screenshots.is_empty() { + /// Issue due screenshot requests. UI phase only: settling counts real layout passes, and the + /// geometry epoch is that of the pass the image will be rendered from. + fn issue_captures(&mut self, ctx: &egui::Context) { + // A discarded pass (popup sizing, `request_discard`) will be re-run: capture only a pass + // whose UI state will actually be present. + if ctx.will_discard() { return; } - let events: Vec<_> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; - Some((token, image.clone())) + let now = screenshot::now(); + let doc = self.document_identity(); + let finished = self.captures.issue(ctx, now, doc, |callback| screenshot::send_command(ctx, callback)); + self.finish_captures(finished); + } + + /// Collect callbacks and expire requests. Logic phase; the image is encoded and written here, + /// on the UI thread, never inside the callback. + fn drain_captures(&mut self, ctx: &egui::Context) { + let now = screenshot::now(); + let doc = self.document_identity(); + // begin_pass supplies the new RawInput before App::logic; run_logic refreshes native + // viewport metadata too. Observe it before any completed callback can be delivered. + let current = screenshot::CaptureGeometry::current(ctx, doc); + self.captures.note_geometry(current); + let observed = self.captures.last_geometry(); + let finished = self.captures.drain(now, observed, doc); + self.finish_captures(finished); + } + + fn finish_captures(&mut self, finished: Vec) { + for f in finished { + let reply = match f.result { + Ok(image) => { + let [w, h] = image.size; + // The encoder takes RGBA bytes; its copy is bounded by the same per-image + // reservation as the retained image, and the image itself is released first. + match w.checked_mul(h).and_then(|n| n.checked_mul(4)) { + Some(bytes) if bytes <= screenshot::MAX_IMAGE_BYTES => { + let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + drop(image); + control::save_screenshot_rgba(self, w, h, &rgba, f.path.as_deref()) + } + _ => serde_json::json!({ + "ok": false, + "error": format!( + "screenshot {} failed: the image exceeds the {} MiB encode budget", + f.token, + screenshot::MAX_IMAGE_BYTES / (1 << 20) + ), + }), } - _ => None, - }) - .collect() - }); - for (token, image) in events { - if let Some(i) = self.pending_screenshots.iter().position(|(t, _, _)| *t == token) { - let (_, path, reply) = self.pending_screenshots.remove(i); - let r = control::save_screenshot(self, &image, path.as_deref()); - let _ = reply.send(r); + } + Err(e) => serde_json::json!({"ok": false, "error": e}), + }; + if let Some(tx) = f.reply { + let _ = tx.send(reply); } } } @@ -2261,14 +2330,26 @@ impl PhotocraftApp { } } -impl eframe::App for PhotocraftApp { - fn on_exit(&mut self) { - if let Err(err) = tool_memory::save_on_exit(self) { - log::warn!("Preferences: could not save the final tool selection: {err}"); - } +impl PhotocraftApp { + /// Install the callback-mode markers in an app creator, before the first `run_ui` on this + /// Context (and again through [`Self::setup_context`] when a host replaces its Context). + /// Installing while a pass is already running cannot mark that pass; it only arms the next. + pub fn install_type_reset_hooks(ctx: &egui::Context) { + type_tool::install_reset_hooks(ctx); } - fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + /// The host's UI tail, after all panels and any shell-side model/focus mutations. + pub fn finish_type_reset_pass(&mut self, ctx: &egui::Context) { + type_tool::finish_reset_pass(self, ctx); + } + + /// The host's logic tail; full UI passes leave availability to their later UI tail. + pub fn finish_type_reset_logic(&mut self, ctx: &egui::Context) { + type_tool::finish_reset_logic(self, ctx); + } + + /// Shared logic body. A wrapping host owns the final reset tail after its own work. + pub fn logic_frame(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { self.cap_frame_rate(); i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language)); // Caps Lock state read from the OS once per frame (see `Services::caps_lock`): the canvas @@ -2295,6 +2376,9 @@ impl eframe::App for PhotocraftApp { if self.live_tokens.poll(ctx, self.ui.theme) { self.checker = None; } + // Finish or expire outstanding captures first, so a new request admitted in this pass + // sees their released budget. + self.drain_captures(ctx); self.drain_control(ctx); native_menu::run(self, ctx); // Switching tools commits, also between the Type and Type Mask tools. @@ -2308,8 +2392,6 @@ impl eframe::App for PhotocraftApp { } transform_tool::end_if_left(self); shape_stroke_ui::sync_editor(self, ctx); - self.collect_screenshots(ctx); - self.issue_screenshots(ctx); prefs_ui::tick(self, ctx); updates_ui::tick(self, ctx); monitor_status::poll_frame(self, ctx, frame); @@ -2326,7 +2408,13 @@ impl eframe::App for PhotocraftApp { jobs_ui::tick(self, ctx); #[cfg(not(target_arch = "wasm32"))] filter_preview_worker::discard_closed(self); - shortcuts::handle(self, ctx); + if type_tool::in_ui_pass(ctx) { + if self.fonts_ready { + shortcuts::handle(self, ctx); + } else { + type_tool::controls_only(self, ctx); + } + } } let arrived: Vec<(String, Vec)> = self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); @@ -2357,55 +2445,55 @@ impl eframe::App for PhotocraftApp { self.place_next_dropped(ctx); // The control transport wakes the UI on arrival (ctx.request_repaint); only poll while a // screenshot is pending. (Polling every 50 ms here made idle apps render at 20 fps.) - if !self.pending_screenshots.is_empty() { + if self.captures.any_pending() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } + // A hidden host runs no UI pass: a queued (or re-queued) capture asks for one. An issued + // capture's own `Screenshot` command forces its paint; asking again would spin a hidden + // window while a slow readback is in flight. Synthetic input queued during hidden ticks + // needs the same wake: its events sit in eframe's preserved input until a UI pass. + if self.captures.any_unissued() || self.synthetic_batch.is_some() || !self.synthetic.is_empty() { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } self.poll_file_dialog(ctx, Some(frame)); } - fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { - let frame_start = gpu_canvas::now_ms(); - // An undecorated window gets its caption back when the toolkit changes its flags - // (maximize, restore, …): take it off again before the frame is laid out (#2246). - if let Some(window_frame) = self.services.window_frame.as_mut() { - window_frame(); - } - // Native menu key equivalents become the key presses they were (see `native_menu`). - if let Some(menu) = self.services.native_menu.as_mut() { - menu.raw_input(raw_input); - } - shortcuts::clipboard_keys(ctx, ctx.text_edit_focused() || self.ui.text_edit.is_some(), raw_input); - // Windows precision touchpads and some Linux Wayland compositors send pinches - // as fractional Ctrl + wheel rather than a native Zoom event (#4566). - // Leave Ctrl+whole-notch mouse scrolling alone, and honour the pinch preference. - if cfg!(any(target_os = "windows", target_os = "linux")) && self.session.prefs().enhanced_controls.zoom_with_trackpad_pinch { - wheel_nav::fold_legacy_pinch(ctx, raw_input); - } - raw_input.events.extend(self.take_synthetic_step()); - // After the control channel's own events (`ui.type`) join the OS's, before any widget. - widgets::cap_text_events(&mut raw_input.events); - self.control_click.normalize(&mut raw_input.events, cfg!(target_os = "macos")); - // A pen held still is no touch screen's press-and-hold right click (stylus.rs). - let pen_down = self.stylus.pen_contacts.observe(&raw_input.events); - stylus::pen_hold_is_no_right_click(ctx, pen_down); - if self.custom_titlebar { - titlebar::release_after_os_resize(ctx, raw_input); - } - if self.stroke_trace.enabled() { - // The OS's moves and the control channel's (injected just above). - let moves = raw_input.events.iter().filter(|e| matches!(e, egui::Event::PointerMoved(_))).count(); - self.stroke_trace.begin_frame(frame_start, moves); - } - } - - fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + /// Shared UI body. A wrapping host must call `finish_type_reset_pass` after return, + /// including when this body returns early without drawing an editor. + pub fn ui_frame(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); + // One control owner even when this callback cannot draw the custom editor. + if !self.fonts_ready + || !self.ui.dialogs.is_empty() + || screen_picker::busy(&ctx) + || self.ui.text_edit.is_none() + || ctx.text_edit_focused() + || ctx.egui_wants_keyboard_input() + { + type_tool::controls_only(self, &ctx); + } + // 0.36 saved panel sizes under `Id::unique(name)`; 0.37 reads parent-scoped keys. Data-only, + // so it runs even before fonts are ready; the old keys stay for a 0.36 rollback. + panels::migrate_panel_state(&ctx); + // A real UI pass, not a hidden `run_logic` tick: record the capture geometry (the + // document identity included) and count one settling pass. `run_logic` leaves these + // input values stale; only this real UI phase counts toward settling. + let geometry = screenshot::CaptureGeometry::current(&ctx, self.document_identity()); + self.captures.note_geometry(geometry); + self.captures.note_pass(); + // A real UI pass consumes the one in-flight synthetic batch (see `inject_synthetic`). + self.begin_synthetic_pass(&ctx); self.hover_layer = None; i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language)); // Fonts registered via set_fonts only take effect next frame; named families would panic now. if !self.fonts_ready { + // egui consumed this pass's input without PhotoCraft handling it: keep the synthetic + // batch for the next pass instead of losing the step. + self.defer_synthetic_pass(); ctx.request_repaint(); self.automation_input = false; + // No editor was drawn this pass: end its IME context rather than keep a stale owner. + type_tool::sync_ime(self, &ctx); return; } let t0 = gpu_canvas::now_ms(); @@ -2429,14 +2517,17 @@ impl eframe::App for PhotocraftApp { // View › Screen Mode › Full Screen Mode: only the image, on black (F or Esc returns). screen_picker::show(&ctx); if screen_picker::busy(&ctx) && screen_picker::showing(&ctx) { - egui::Modal::new(egui::Id::new("screen-color-wait")).show(&ctx, |ui| { + egui::Modal::new(egui::Id::unique("screen-color-wait")).show(&ctx, |ui| { ui.spinner(); ui.label(tl!("Pick a screen pixel or press Esc to cancel")); if ui.button(tl!("Cancel")).clicked() { screen_picker::cancel(&ctx); } }); + // This pass handled its input (the modal above ran): deliver the synthetic batch. + self.finish_synthetic_pass(); self.automation_input = false; + type_tool::sync_ime(self, &ctx); gpu_status::check_window(self, &ctx); return; } @@ -2519,12 +2610,15 @@ impl eframe::App for PhotocraftApp { tool_memory::persist(self, &ctx); // So are View › Extras, Show and Snap To (#3762). view_cmds::persist(self, &ctx); + // Remember the 0.37 panel sizes so a later 0.36 rollback's newer old-key values win on + // the next upgrade (see `panels::migrate_panel_state`). + panels::remember_panel_state(&ctx); self.automation_input = false; shape_stroke_ui::finish_frame(self, &ctx); native_menu::sync(self, &ctx); if screen_picker::busy(&ctx) { // Block input without a backdrop: the worker may still be capturing the screen. - egui::Modal::new(egui::Id::new("screen-color-wait")).frame(egui::Frame::NONE).backdrop_color(egui::Color32::TRANSPARENT).show(&ctx, |_| {}); + egui::Modal::new(egui::Id::unique("screen-color-wait")).frame(egui::Frame::NONE).backdrop_color(egui::Color32::TRANSPARENT).show(&ctx, |_| {}); ctx.set_cursor_icon(egui::CursorIcon::Wait); } let ui_ms = gpu_canvas::now_ms() - t0; @@ -2549,9 +2643,14 @@ impl eframe::App for PhotocraftApp { if info.present.is_empty() { gpu } else { format!("{gpu}, {}", info.present) } }); } + // This pass handled its input: the in-flight synthetic batch is delivered once and the + // next step may be injected on the next frame. + self.finish_synthetic_pass(); // Synthetic input is injected one press/release step per frame: keep frames coming until // the queue is empty, then release control replies waiting on it. - if !self.synthetic.is_empty() { + // Only a pass that reached the end of the UI and delivered the in-flight batch counts as + // settled input; an early-returning pass keeps the batch (and the waiting replies). + if !self.synthetic.is_empty() || self.synthetic_batch.is_some() { ctx.request_repaint(); } else if !self.input_waiters.is_empty() { for w in self.input_waiters.drain(..) { @@ -2563,11 +2662,85 @@ impl eframe::App for PhotocraftApp { jobs_ui::keep_polling(self, &ctx); // Menus and buttons in this frame may have asked for a file dialog. self.poll_file_dialog(&ctx, Some(frame)); + // The Type tool's IME descriptor is the last writer of this pass: every panel and the + // canvas have finished mutating the model, and an absent/focus-moved editor ends here. + type_tool::sync_ime(self, &ctx); + // Issue any due `ui.screenshot` now that this pass's state is settled: a queued request + // that has waited its passes and animation delay captures exactly this frame. + self.issue_captures(&ctx); // Last, after everything drawn this frame: a lost window device can't draw it (#3438). gpu_status::check_window(self, &ctx); } } +impl eframe::App for PhotocraftApp { + fn on_exit(&mut self) { + if let Err(err) = tool_memory::save_on_exit(self) { + log::warn!("Preferences: could not save the final tool selection: {err}"); + } + // Release a pending screenshot's image budget and answer its control request; the + // callback is inert afterwards (it holds only a weak slot). + let finished = self.captures.cancel_all("the application is closing"); + self.finish_captures(finished); + } + + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + self.logic_frame(ctx, frame); + self.finish_type_reset_logic(ctx); + } + + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + let frame_start = gpu_canvas::now_ms(); + // An undecorated window gets its caption back when the toolkit changes its flags + // (maximize, restore, …): take it off again before the frame is laid out (#2246). + if let Some(window_frame) = self.services.window_frame.as_mut() { + window_frame(); + } + // Native menu key equivalents become the key presses they were (see `native_menu`). + if let Some(menu) = self.services.native_menu.as_mut() { + menu.raw_input(raw_input); + } + shortcuts::clipboard_keys(ctx, ctx.text_edit_focused() || self.ui.text_edit.is_some(), raw_input); + // Windows precision touchpads and some Linux Wayland compositors send pinches + // as fractional Ctrl + wheel rather than a native Zoom event (#4566). + // Leave Ctrl+whole-notch mouse scrolling alone, and honour the pinch preference. + if cfg!(any(target_os = "windows", target_os = "linux")) && self.session.prefs().enhanced_controls.zoom_with_trackpad_pinch { + wheel_nav::fold_legacy_pinch(ctx, raw_input); + } + self.inject_synthetic(ctx, raw_input); + // After the control channel's own events (`ui.type`) join the OS's, before any widget. + widgets::cap_text_events(&mut raw_input.events); + self.control_click.normalize(&mut raw_input.events, cfg!(target_os = "macos")); + // A pen held still is no touch screen's press-and-hold right click (stylus.rs). + let pen_down = self.stylus.pen_contacts.observe(&raw_input.events); + stylus::pen_hold_is_no_right_click(ctx, pen_down); + if self.custom_titlebar { + titlebar::release_after_os_resize(ctx, raw_input); + } + if self.stroke_trace.enabled() { + // The OS's moves and the control channel's (injected just above). + let moves = raw_input.events.iter().filter(|e| matches!(e, egui::Event::PointerMoved(_))).count(); + self.stroke_trace.begin_frame(frame_start, moves); + } + } + + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + self.ui_frame(ui, frame); + self.finish_type_reset_pass(ui.ctx()); + } +} + +/// Remove one contiguous run equal to `batch` from `events` (eframe's preserved synthetic +/// input). A no-op when the run is not present. +fn remove_event_run(events: &mut Vec, batch: &[egui::Event]) { + if batch.is_empty() || batch.len() > events.len() { + return; + } + if let Some(pos) = events.windows(batch.len()).position(|w| w == batch) { + events.drain(pos..pos + batch.len()); + } +} + #[cfg(not(target_arch = "wasm32"))] fn read_dropped(f: &(dyn egui::DroppedFile + Send + Sync)) -> Result, String> { f.bytes() @@ -2860,6 +3033,99 @@ fn thumb_image(frame: photocraft_geom::Rect, side: usize, mut f: impl FnMut(i32, } impl PhotocraftApp { + /// The document/focus identity the in-flight synthetic batch was queued for. + fn synthetic_target(&self, ctx: &egui::Context) -> SyntheticTarget { + SyntheticTarget { document: self.document_identity(), focus: ctx.memory(|m| m.focused()) } + } + + fn synthetic_target_matches(&self, ctx: &egui::Context) -> bool { + self.synthetic_snapshot.is_some_and(|queued| queued.matches(self.synthetic_target(ctx))) + } + + /// Put at most one queued synthetic batch into this pass's input. + /// + /// The batch is appended once and tracked; `begin_pass` consumes it. While it is in flight no + /// further step is drained, so hidden logic ticks cannot accumulate several batches into one + /// pass. A document or focus change observed here is before eframe consumes the events, so the + /// batch is dropped and its waiting control replies fail instead of landing somewhere else. + /// If a pass consumed it but PhotoCraft returned before handling input (fonts not ready), the + /// same batch is appended again on the next hook, exactly once. + fn inject_synthetic(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + if self.synthetic_batch.is_some() { + if !self.synthetic_target_matches(ctx) { + // eframe may already carry the batch in its preserved input: withdraw exactly + // those events before the host consumes them, then fail the waiting replies. + if let Some(batch) = self.synthetic_batch.as_ref() { + remove_event_run(&mut raw_input.events, batch); + } + self.cancel_synthetic("the active document or focus changed while the input was queued"); + return; + } + if self.synthetic_reinject { + if let Some(batch) = self.synthetic_batch.clone() { + raw_input.events.extend(batch); + } + self.synthetic_reinject = false; + self.automation_input = true; + } + return; + } + if self.synthetic.is_empty() { + return; + } + let events = self.take_synthetic_step(); + if events.is_empty() { + return; + } + self.synthetic_snapshot = Some(self.synthetic_target(ctx)); + raw_input.events.extend(events.iter().cloned()); + self.synthetic_batch = Some(events); + } + + /// A pass begins with the in-flight batch. The events are already in this pass's input, so a + /// target change detected now cannot withdraw them: deliver them (never half-apply) and keep + /// `automation_input` set so the commands they trigger still go through the automation policy. + /// `inject_synthetic` performs the withdrawable check, before the host consumes the events. + /// Called at the start of `App::ui` only (a hidden `run_logic` tick consumes no input). + fn begin_synthetic_pass(&mut self, ctx: &egui::Context) { + if self.synthetic_batch.is_none() { + return; + } + if !self.synthetic_target_matches(ctx) { + log::warn!("synthetic input: the document or focus changed inside the pass; delivering it as automation input"); + } + self.automation_input = true; + } + + /// One full UI pass handled the in-flight batch: deliver it once and let the next step in. + fn finish_synthetic_pass(&mut self) { + let _ = self.synthetic_batch.take(); + self.synthetic_snapshot = None; + self.synthetic_reinject = false; + } + + /// The pass returned before the input handling (fonts not ready): keep the batch and have the + /// next hook append it again exactly once, because egui already consumed the events. + fn defer_synthetic_pass(&mut self) { + if self.synthetic_batch.is_some() { + self.synthetic_reinject = true; + } + } + + /// Drop the in-flight batch and fail the replies waiting on it. + fn cancel_synthetic(&mut self, reason: &str) { + let pending = self.synthetic_batch.take().is_some() || !self.synthetic.is_empty(); + self.synthetic.clear(); + self.synthetic_snapshot = None; + self.synthetic_reinject = false; + self.automation_input = false; + if pending || !self.input_waiters.is_empty() { + for w in self.input_waiters.drain(..) { + let _ = w.send(serde_json::json!({"ok": false, "error": format!("input cancelled: {reason}")})); + } + } + } + /// Next step of queued synthetic input (automation): events up to and including the first /// release, so egui sees press and release in separate frames. Hosts that don't call /// `raw_input_hook` (offscreen harnesses) feed these to their input themselves. @@ -2876,9 +3142,12 @@ impl PhotocraftApp { events } - /// Install fonts, image loaders and the theme. Call from the app creator when possible so the - /// very first frame renders; otherwise `logic` does it and the first frame is skipped. + /// Install fonts, image loaders and the theme. Call from the app creator (and again for a + /// replacement Context) before the first `run_ui` so the very first frame renders and the + /// Type reset begin/end pass markers exist; otherwise `logic` does it and the first frame is + /// skipped, with the begin-pass marker necessarily starting on the following pass. pub fn setup_context(ctx: &egui::Context, kind: theme::ThemeKind) { + Self::install_type_reset_hooks(ctx); ctx.add_plugin(tool_cursor::CursorLifecycle); ctx.add_plugin(single_line_drag::SingleLineDrag::default()); theme::install_fonts(ctx); @@ -3215,6 +3484,12 @@ impl PhotocraftApp { } } +#[cfg(test)] +mod lifecycle_tests; + +#[cfg(test)] +mod screenshot_tests; + #[cfg(test)] mod navigator_gpu_tests; diff --git a/crates/ui-egui/src/lifecycle_tests.rs b/crates/ui-egui/src/lifecycle_tests.rs new file mode 100644 index 0000000000..d9506a6907 --- /dev/null +++ b/crates/ui-egui/src/lifecycle_tests.rs @@ -0,0 +1,191 @@ +//! Hidden-tick and synthetic-input lifecycle regressions. +//! +//! The host runs `App::logic` without a UI pass for hidden/occluded windows; eframe preserves the +//! raw input appended by `raw_input_hook` until a real pass consumes it. These tests exercise that +//! order without a renderer: at most one synthetic batch may be in flight, a pass that returns +//! early must not lose it (and must not deliver it twice), and a batch queued for one document +//! must not be replayed into another. + +use crate::{PhotocraftApp, Services}; +use eframe::App as _; +use egui::{Context, Event, Key, Modifiers, RawInput}; +use photocraft_engine::Session; +use serde_json::json; + +fn app_with_doc() -> PhotocraftApp { + let mut app = PhotocraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 32, "height": 32})).expect("a document"); + app +} + +fn press(key: Key) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } +} + +fn release(key: Key) -> Event { + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE } +} + +fn hook(app: &mut PhotocraftApp, ctx: &Context) -> Vec { + let mut raw = RawInput::default(); + app.inject_synthetic(ctx, &mut raw); + raw.events +} + +/// Hidden logic ticks still receive the current viewport's native geometry. The default ROOT +/// entry has no geometry, so it cannot represent a window with a capturable size. +fn host_input(viewport: egui::ViewportId) -> RawInput { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + let mut raw = RawInput { viewport_id: viewport, screen_rect: Some(rect), focused: true, ..Default::default() }; + raw.viewports + .insert(viewport, egui::ViewportInfo { inner_rect: Some(rect), native_pixels_per_point: Some(1.0), minimized: Some(false), ..Default::default() }); + raw +} + +#[test] +fn one_batch_at_a_time_across_hidden_ticks() { + let ctx = Context::default(); + let mut app = app_with_doc(); + app.synthetic.extend([press(Key::V), release(Key::V), press(Key::W), release(Key::W)]); + // The first hidden tick injects the first press/release pair. + let first = hook(&mut app, &ctx); + assert_eq!(first.len(), 2, "one step: the press and its release"); + // Further hidden ticks must not drain the next step into eframe's preserved buffer. + assert!(hook(&mut app, &ctx).is_empty()); + assert!(hook(&mut app, &ctx).is_empty()); + // A real UI pass consumes the batch; only then may the next step start. + app.begin_synthetic_pass(&ctx); + app.finish_synthetic_pass(); + let next = hook(&mut app, &ctx); + assert_eq!(next.len(), 2, "the second step starts after the pass"); +} + +#[test] +fn an_early_returning_pass_reinjects_the_batch_exactly_once() { + let ctx = Context::default(); + let mut app = app_with_doc(); + app.synthetic.extend([press(Key::A)]); + assert_eq!(hook(&mut app, &ctx).len(), 1); + // A pass began (egui consumed the events), then returned before handling input: fonts not + // ready. Keep the same batch and put it back on the next hook. + app.begin_synthetic_pass(&ctx); + app.defer_synthetic_pass(); + let again = hook(&mut app, &ctx); + assert_eq!(again.len(), 1, "the same step is delivered again"); + assert!(matches!(again[0], Event::Key { key: Key::A, pressed: true, .. })); + // A second hidden tick before the UI pass must not append the step a second time. + assert!(hook(&mut app, &ctx).is_empty(), "the reinjection is consumed once"); + app.begin_synthetic_pass(&ctx); + app.finish_synthetic_pass(); + assert!(hook(&mut app, &ctx).is_empty(), "then it is delivered once and done"); +} + +#[test] +fn a_document_change_before_injection_drops_the_batch_and_replies() { + let ctx = Context::default(); + let mut app = app_with_doc(); + app.synthetic.extend([press(Key::V), release(Key::V)]); + assert!(!hook(&mut app, &ctx).is_empty()); + let (tx, rx) = std::sync::mpsc::channel(); + app.input_waiters.push(tx); + // The document changed while the events were still in flight: the next hook sees it before + // the host consumes them, so it withdraws the batch and fails the waiting reply. + app.run("file.new", json!({"width": 16, "height": 16})).expect("a second document"); + assert!(hook(&mut app, &ctx).is_empty(), "nothing is delivered to the new document"); + assert!(app.synthetic_batch.is_none(), "the stale batch is dropped"); + let reply = rx.recv().expect("the waiting control request gets an answer"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("document or focus changed"))); +} + +#[test] +fn a_document_change_inside_the_pass_is_delivered_and_not_reported_as_cancelled() { + let ctx = Context::default(); + let mut app = app_with_doc(); + app.synthetic.extend([press(Key::V), release(Key::V)]); + assert!(!hook(&mut app, &ctx).is_empty()); + let (tx, rx) = std::sync::mpsc::channel(); + app.input_waiters.push(tx); + // `logic` opens another document after the hook consumed the events into this pass. They + // cannot be withdrawn now. Treat the batch as delivered (automation-gated) rather than + // failing the reply while the input still lands. + app.run("file.new", json!({"width": 16, "height": 16})).expect("a second document"); + app.begin_synthetic_pass(&ctx); + assert!(app.automation_input, "the delivered batch keeps the automation policy"); + assert!(app.synthetic_batch.is_some(), "the pass still owns the batch until it finishes"); + assert!(rx.try_recv().is_err(), "no cancellation reply for input that was delivered"); + app.finish_synthetic_pass(); + assert!(app.synthetic_batch.is_none()); +} + +#[test] +fn a_preserved_batch_is_withdrawn_from_the_raw_input_on_a_document_change() { + let ctx = Context::default(); + let mut app = app_with_doc(); + app.synthetic.extend([press(Key::V), release(Key::V)]); + let first = hook(&mut app, &ctx); + assert_eq!(first.len(), 2); + // A hidden logic tick preserved the events for the next pass; meanwhile the document changed. + app.run("file.new", json!({"width": 16, "height": 16})).expect("a second document"); + let mut preserved = RawInput { events: first, ..Default::default() }; + app.inject_synthetic(&ctx, &mut preserved); + assert!(preserved.events.is_empty(), "the stale events are withdrawn from preserved input"); + assert!(app.synthetic_batch.is_none()); +} + +#[test] +fn an_edit_in_the_same_document_does_not_cancel_the_queued_batch() { + let ctx = Context::default(); + let mut app = app_with_doc(); + app.synthetic.extend([press(Key::L), release(Key::L)]); + assert!(!hook(&mut app, &ctx).is_empty()); + let (tx, rx) = std::sync::mpsc::channel(); + app.input_waiters.push(tx); + // A command edits the same document while the step is in flight: the revision changes, the + // document activation does not, so the step is not cancelled. + app.run("layer.new.layer", json!({"name": "queued"})).expect("an edit"); + let mut preserved = RawInput::default(); + app.inject_synthetic(&ctx, &mut preserved); + assert!(app.synthetic_batch.is_some(), "the batch for this document survives an edit"); + assert!(rx.try_recv().is_err(), "no cancellation reply"); + app.begin_synthetic_pass(&ctx); + app.finish_synthetic_pass(); +} + +/// A hidden host runs `logic` without a UI pass. Both a capture that is waiting for its first +/// geometry and a queued synthetic step must ask the host for a paint, or the window stays +/// hidden forever and the control request times out. +#[test] +fn a_logic_tick_requests_a_hidden_paint_for_waiting_captures_and_queued_input() { + let ctx = Context::default(); + let mut app = app_with_doc(); + let mut frame = eframe::Frame::_new_kittest(); + let (tx, _rx) = std::sync::mpsc::channel(); + let document = app.document_identity(); + app.captures.admit(90, None, tx, document, 0.0, crate::screenshot::now()).expect("parked before the first pass"); + let out = ctx.run_logic(&host_input(egui::ViewportId::ROOT), |ctx| app.logic(ctx, &mut frame)); + let paints = |out: &egui::LogicOutput| { + out.viewport_commands.get(&egui::ViewportId::ROOT).is_some_and(|commands| commands.contains(&egui::ViewportCommand::RequestPaintWhileHidden)) + }; + assert!(paints(&out), "a capture waiting for geometry keeps the hidden window painting"); + + app.synthetic.extend([press(Key::V), release(Key::V)]); + let mut raw = host_input(egui::ViewportId::ROOT); + app.inject_synthetic(&ctx, &mut raw); + let out = ctx.run_logic(&raw, |ctx| app.logic(ctx, &mut frame)); + assert!(paints(&out), "queued synthetic input keeps the hidden window painting"); +} + +#[test] +fn a_finished_pass_does_not_replay_the_batch() { + let ctx = Context::default(); + let mut app = app_with_doc(); + app.synthetic.extend([press(Key::B), release(Key::B)]); + assert_eq!(hook(&mut app, &ctx).len(), 2); + app.begin_synthetic_pass(&ctx); + app.finish_synthetic_pass(); + // A second pass in the same frame (a popup sizing pass) must see no events from us. + let mut second_pass = RawInput::default(); + app.inject_synthetic(&ctx, &mut second_pass); + assert!(second_pass.events.is_empty()); +} diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 210754ef32..8d5a855515 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -904,7 +904,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut events: Vec = Vec::new(); let mut mods = egui::Modifiers::NONE; let pinch_zooms = app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch; - egui::Area::new(egui::Id::new("liquify-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("liquify-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.distort.liquify.as_mut() else { return }; d.upload(ctx); let (full, _) = ui.allocate_exact_size(screen.size(), Sense::hover()); @@ -984,7 +984,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { widgets::checkbox(ui, &mut d.opts.show_mesh, tl!("Show Mesh")); ui.horizontal(|ui| { ui.label(tl!("Mesh Size")); - widgets::dropdown(ui, "liquify-mesh-size", &mut d.opts.mesh_size, &[(0, tl!("Small")), (1, tl!("Medium")), (2, tl!("Large"))], 110.0); + widgets::dropdown( + ui, + "liquify-mesh-size", + tl!("Mesh Size"), + &mut d.opts.mesh_size, + &[(0, tl!("Small")), (1, tl!("Medium")), (2, tl!("Large"))], + 110.0, + ); }); widgets::checkbox(ui, &mut d.opts.show_mask, tl!("Show Mask")); widgets::checkbox(ui, &mut d.opts.show_backdrop, tl!("Show Backdrop")); @@ -992,12 +999,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.horizontal(|ui| { ui.label(tl!("Use")); let names: Vec<(Option, &str)> = backdrop_layers.iter().map(|(id, n)| (*id, n.as_str())).collect(); - widgets::dropdown_literal(ui, "liquify-backdrop-use", &mut d.opts.backdrop_layer, &names, 150.0); + widgets::dropdown_literal(ui, "liquify-backdrop-use", tl!("Layer"), &mut d.opts.backdrop_layer, &names, 150.0); }); ui.horizontal(|ui| { ui.label(tl!("Mode")); let modes = [(BackdropMode::InFront, tl!("In Front")), (BackdropMode::Behind, tl!("Behind")), (BackdropMode::Blend, tl!("Blend"))]; - widgets::dropdown(ui, "liquify-backdrop-mode", &mut d.opts.backdrop_mode, &modes, 150.0); + widgets::dropdown(ui, "liquify-backdrop-mode", tl!("Mode"), &mut d.opts.backdrop_mode, &modes, 150.0); }); widgets::slider_row(ui, tl!("Opacity"), &mut d.opts.backdrop_opacity, 0.0..=100.0, "", None); } @@ -1034,11 +1041,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { d.zoom = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).clamp(ZOOM_MIN, 4.0); d.center = [f64::from(d.canvas.x0) + f64::from(cw) / 2.0, f64::from(d.canvas.y0) + f64::from(ch) / 2.0]; } - let resp = ui.interact(area, egui::Id::new("liquify-preview"), Sense::click_and_drag()); + let resp = ui.interact(area, egui::Id::unique("liquify-preview"), Sense::click_and_drag()); // The wheel zooms about the pointer, ×1.1 a notch, and so does a trackpad pinch (unless // Preferences › Enhanced Controls › Zoom with Trackpad Pinch is off); a sideways scroll // pans (#7210). Read every frame, as `wheel_nav` asks, so its modifier state follows. - let wheel = crate::wheel_nav::read(ctx, true, pinch_zooms, egui::Id::new("liquify-preview-wheel"), resp.hovered()); + let wheel = crate::wheel_nav::read(ctx, true, pinch_zooms, egui::Id::unique("liquify-preview-wheel"), resp.hovered()); if let Some(hp) = resp.hover_pos() { let zoom = match wheel { Some(crate::wheel_nav::Wheel::Zoom(f)) => Some((d.zoom * f).clamp(ZOOM_MIN, ZOOM_MAX)), @@ -1705,7 +1712,7 @@ mod tests { }; let mut out = ctx.run_ui(raw, |ui| { let ctx = ui.ctx(); - ctx.memory_mut(|m| m.request_focus(egui::Id::new("liquify-slider-focus"))); + ctx.memory_mut(|m| m.request_focus(egui::Id::unique("liquify-slider-focus"))); assert!(ctx.egui_wants_keyboard_input()); crate::shortcuts::handle(&mut app, ctx); }); @@ -1744,7 +1751,7 @@ mod tests { // One frame of the real shortcut handler, then a focused single-line text edit (the // numeric fields are text edits), as the app orders them. let mut text = String::from("100"); - let field = egui::Id::new("liquify-size-field"); + let field = egui::Id::unique("liquify-size-field"); let mut frame = |app: &mut PhotocraftApp, events: Vec| { let raw = egui::RawInput { events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { @@ -1786,7 +1793,7 @@ mod tests { // With a text edit focused, `[` belongs to the field. let mut text = String::new(); - let field = egui::Id::new("liquify-size-field"); + let field = egui::Id::unique("liquify-size-field"); let focused_frame = |app: &mut PhotocraftApp, text: &mut String, events: Vec| { let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { crate::shortcuts::handle(app, ui.ctx()); @@ -1868,6 +1875,7 @@ mod tests { let notch = ctx.options(|o| o.input_options.line_scroll_speed); let wheel = egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 1.0), modifiers: egui::Modifiers::NONE, diff --git a/crates/ui-egui/src/mask_props_ui.rs b/crates/ui-egui/src/mask_props_ui.rs index 9c74a8a18f..89e5ea4d3b 100644 --- a/crates/ui-egui/src/mask_props_ui.rs +++ b/crates/ui-egui/src/mask_props_ui.rs @@ -72,7 +72,7 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Laye fn sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: u64, document: u64, command: &str, prefix: &str, density: f32, feather: f32) { // Like brush settings, track pointer presses even on frames without a value change. // slider_row also merges numeric scrubbing, whose drag flags are not in its Response. - let key = ui.id().with("gesture"); + let key = ui.scope_id().with("gesture"); let pass = ui.ctx().cumulative_pass_nr(); let pressed = ui.input(|i| i.pointer.any_pressed()); let gesture = ui.data_mut(|d| { diff --git a/crates/ui-egui/src/mask_thumbs_ui.rs b/crates/ui-egui/src/mask_thumbs_ui.rs index 9e2d8e8210..c19b14928d 100644 --- a/crates/ui-egui/src/mask_thumbs_ui.rs +++ b/crates/ui-egui/src/mask_thumbs_ui.rs @@ -112,7 +112,7 @@ pub fn paint( for (kind, enabled, linked) in masks(l) { // The chain sits in the gap before the thumbnail, centred between the two. let chain = Rect::from_min_max(pos2(*x - 4.0, cy - ts / 2.0), pos2(*x - 4.0 + CHAIN_W, cy + ts / 2.0)); - let resp = ui.interact(chain, ui.id().with(("mask-chain", l.id.0, kind == MaskKind::Vector)), Sense::click()); + let resp = ui.interact(chain, ui.scope_id().with(("mask-chain", l.id.0, kind == MaskKind::Vector)), Sense::click()); if linked { paint_chain(painter, chain.center(), if resp.hovered() { t.text } else { t.icon }); } else if resp.hovered() { @@ -122,11 +122,7 @@ pub fn paint( let resp = resp.on_hover_text(if linked { tl!("Unlink the mask from the layer") } else { tl!("Link the mask to the layer") }); let template = "{verb} {mask} {layer}"; resp.widget_info(|| { - egui::WidgetInfo::labeled( - egui::WidgetType::Button, - true, - crate::i18n::fmt(tl!(template), &[("verb", tl!(verb)), ("mask", tl!(what)), ("layer", &name)]), - ) + egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::fmt(tl!(template), &[("verb", tl!(verb)), ("mask", tl!(what)), ("layer", &name)])) }); if resp.clicked() { out.clicked = true; @@ -175,7 +171,7 @@ type Rects = (Vec<(MaskKind, Rect)>, Vec<(MaskKind, Rect)>); /// Memory key of the per-layer record of where the mask thumbnails and chains were drawn. fn rects_id() -> egui::Id { - egui::Id::new("mask-thumb-rects") + egui::Id::unique("mask-thumb-rects") } /// The mask thumbnails and chains last drawn for layer `id` (for tests and automation). @@ -255,7 +251,7 @@ pub fn paint_brackets(painter: &Painter, r: Rect, color: Color32) { } fn bracket_id() -> egui::Id { - egui::Id::new("layer-target-brackets") + egui::Id::unique("layer-target-brackets") } /// Where the target brackets were last drawn (for tests and automation). diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 20eed5b1fe..0861b5c062 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -51,11 +51,11 @@ pub struct Keys { } fn keys_id() -> Id { - Id::new("pc-menu-bar-keys") + Id::unique("pc-menu-bar-keys") } fn focus_id() -> Id { - Id::new("pc-menu-bar-focus") + Id::unique("pc-menu-bar-focus") } /// Is the keyboard on the menu bar (no menu open yet)? The shortcuts leave the keys to it then, diff --git a/crates/ui-egui/src/menu_keys_tests.rs b/crates/ui-egui/src/menu_keys_tests.rs index 0ac4533b26..e77bbfc3f2 100644 --- a/crates/ui-egui/src/menu_keys_tests.rs +++ b/crates/ui-egui/src/menu_keys_tests.rs @@ -85,6 +85,7 @@ fn alt_with_a_wheel_scroll_or_a_lost_focus_is_no_tap() { h.run_steps(2); h.event_modifiers( egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, @@ -123,6 +124,7 @@ fn a_held_alt_repeating_after_the_wheel_is_no_tap() { h.run_steps(2); h.event_modifiers( egui::Event::MouseWheel { + source: egui::MouseWheelSource::Wheel, unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), modifiers: egui::Modifiers::ALT, diff --git a/crates/ui-egui/src/menu_nav.rs b/crates/ui-egui/src/menu_nav.rs index 93b29ce7d9..9f5de15c46 100644 --- a/crates/ui-egui/src/menu_nav.rs +++ b/crates/ui-egui/src/menu_nav.rs @@ -93,11 +93,11 @@ pub struct Nav { } fn nav_id() -> Id { - Id::new("pc-menu-nav") + Id::unique("pc-menu-nav") } fn open_id() -> Id { - Id::new("pc-menu-open") + Id::unique("pc-menu-open") } /// Is one of the menu bar's menus open? (Shortcuts leave the keys to it meanwhile.) @@ -462,14 +462,14 @@ pub fn level_room_in(screen: Rect, visible: Rect, bar_bottom: Option, depth pub fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, rows: impl FnOnce(&mut Ui, &mut Nav)) { let ctx = ui.ctx().clone(); let level = depth.saturating_sub(1); - nav.begin_level(level, find_menu_root(ui).id); + nav.begin_level(level, find_menu_root(ui).scope_id); let screen = ctx.content_rect(); // Only the part of the window on its monitor, clear of the taskbar, can be seen (#315). let visible = crate::work_area::visible_rect(&ctx); // The menu frame's margin and stroke around the rows. let frame = ui.spacing().menu_margin.sum().y + 2.0; let anchor = nav.anchors.get(level).copied().flatten(); - let key = ui.id().with(("pc-menu-level", depth)); + let key = ui.scope_id().with(("pc-menu-level", depth)); // Rows' height from the last frame: does this level overflow? let content: Option = ctx.data(|d| d.get_temp(key)); let room = level_room_in(screen, visible, nav.bar_bottom, depth, anchor, content, frame); @@ -481,12 +481,18 @@ pub fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, rows: impl FnOnce(&mut Ui // pt, on the first): a scroll area never grows past that, so every menu stuck at that height // and scrolled even on a tall window (#235). Ask for the room the window has; auto-shrink then // fits the area to its rows, so a menu scrolls only when they don't fit. - let out = egui::ScrollArea::vertical() + // egui 0.37 made `ScrollAreaState::offset` private: a scroll target requested by last + // frame's arrows is applied through the builder instead of writing the stored state. + let mut area = egui::ScrollArea::vertical() .id_salt(("menu-level", depth)) .max_height(height) .min_scrolled_height(height) - .scroll_source(egui::containers::scroll_area::ScrollSource::ALL) - .show(ui, |ui| rows(ui, nav)); + .scroll_source(egui::containers::scroll_area::ScrollSource::ALL); + if let Some(target) = ctx.data(|d| d.get_temp::(scroll_request_id(key))) { + area = area.vertical_scroll_offset(target); + } + let out = area.show(ui, |ui| rows(ui, nav)); + ctx.data_mut(|d| d.remove_temp::(scroll_request_id(key))); let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0); ctx.data_mut(|d| d.insert_temp(key, out.content_size.y)); if let Some(v) = nav.views.get_mut(level) { @@ -505,7 +511,7 @@ pub fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, rows: impl FnOnce(&mut Ui let t = crate::theme::Tokens::get(&ctx); let x = ui.min_rect().x_range(); let max = (out.content_size.y - out.inner_rect.height()).max(0.0); - let offset = out.state.offset.y; + let offset = out.state.clamped_offset().y; let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1); let mut target = offset; for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] { @@ -521,7 +527,7 @@ pub fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, rows: impl FnOnce(&mut Ui let id = key.with(dir < 0.0); let r = ui.interact(strip, id, Sense::hover()); let label = if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }; - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, live, tl!(label))); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, live, tl!(label))); if live && pointer_on(ui, strip) { target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max); ctx.request_repaint(); @@ -535,12 +541,17 @@ pub fn level(ui: &mut Ui, depth: usize, nav: &mut Nav, rows: impl FnOnce(&mut Ui } } if target != offset { - let mut st = out.state; - st.offset.y = target; - st.store(&ctx, out.id); + // Applied to the scroll area on its next frame (egui 0.37 keeps the offset private). + ctx.data_mut(|d| d.insert_temp(scroll_request_id(key), target)); + ctx.request_repaint(); } } +/// The temp key that carries an arrow/wheel scroll target to the next `ScrollArea` build. +fn scroll_request_id(key: egui::Id) -> egui::Id { + key.with("scroll-request") +} + #[cfg(test)] #[path = "menu_nav_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index 87c5498f7d..24fa76e166 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -45,6 +45,7 @@ fn reported_menus_stay_above_the_panel_when_the_window_position_is_unknown() { break; } h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, -60.0), modifiers: egui::Modifiers::NONE, @@ -164,6 +165,7 @@ fn the_mouse_wheel_scrolls_a_long_menu() { break; } h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, -60.0), modifiers: egui::Modifiers::NONE, @@ -271,9 +273,9 @@ fn left_and_right_switch_menus_and_shortcuts_wait_while_one_is_open() { fn help_search_keeps_the_arrows_and_space_that_edit_it() { let mut h = harness((1280.0, 720.0, 1.0)); open(&mut h, "Help"); - let field = egui::Id::new("help-menu-search").with("field"); + let field = egui::Id::unique("help-menu-search").with("field"); assert_eq!(h.ctx.memory(|m| m.focused()), Some(field), "the search field has the keyboard"); - let text = |h: &Harness<'static, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("help-menu-search"))).unwrap_or_default(); + let text = |h: &Harness<'static, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::unique("help-menu-search"))).unwrap_or_default(); let typing = |h: &mut Harness<'static, PhotocraftApp>, s: &str| { h.event(egui::Event::Text(s.to_string())); h.run_steps(3); @@ -411,6 +413,7 @@ fn a_short_window_still_scrolls_the_file_menu_to_its_last_row() { break; } h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, -60.0), modifiers: egui::Modifiers::NONE, @@ -565,6 +568,7 @@ fn menus_stay_above_the_taskbar_and_scroll_to_their_last_row() { h.hover_at(view.center()); for _ in 0..60 { h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, 120.0), modifiers: egui::Modifiers::NONE, @@ -932,6 +936,7 @@ fn a_scrolled_menu_reopens_and_stays_open_with_a_hidden_submenu_row_under_its_ti h.hover_at(egui::pos2(view.center().x, view.bottom() - 2.0)); let wheel = |h: &mut Harness<'static, PhotocraftApp>, dy: f32| { h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, dy), modifiers: Modifiers::NONE, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 962c79f828..2c2646fb4a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1324,7 +1324,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { } fn press_gesture_id() -> egui::Id { - egui::Id::new("menu-press-gesture") + egui::Id::unique("menu-press-gesture") } /// A menu title's open/close command this frame, the press opens a closed menu (and @@ -1432,7 +1432,7 @@ pub fn search_items<'a>(items: &'a [MenuItem], query: &str, lang: crate::i18n::L fn help_search(ui: &mut egui::Ui, items: &[MenuItem], opening: bool, clicked: &mut Option, nav: &mut crate::menu_nav::Nav) { let ctx = ui.ctx().clone(); let lang = crate::i18n::current(); - let text_id = egui::Id::new("help-menu-search"); + let text_id = egui::Id::unique("help-menu-search"); let pass_id = text_id.with("pass"); // A fresh opening of the Help menu starts empty, with the field focused. let pass = ctx.cumulative_pass_nr(); @@ -1601,7 +1601,7 @@ fn submenu_row(ui: &mut egui::Ui, title: egui::WidgetText, content: impl FnOn if open { ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open; } - let response = ui.add(egui::Button::new(title).right_text(SubMenuButton::RIGHT_ARROW)); + let response = ui.add(egui::Button::new(title).right_text(SubMenuButton::arrow_atom(None))); ui.style_mut().visuals.widgets.inactive = inactive; // The rect egui tests the pointer against (`SubMenu::show`), against the part that can be seen. let reach = response.rect.expand2(ui.spacing().item_spacing / 2.0); @@ -1840,12 +1840,15 @@ mod tests { egui::CentralPanel::default().show(ui, |ui| { let (rect, _) = ui.allocate_exact_size(egui::vec2(80.0, 24.0), egui::Sense::hover()); let shifted = rect.translate(egui::vec2(p.x - rect.center().x, p.y - rect.center().y)); - let response = ui.interact(shifted, egui::Id::new("menu-title-test"), egui::Sense::hover()); + let response = ui.interact(shifted, egui::Id::unique("menu-title-test"), egui::Sense::hover()); title = Some(response); }); - egui::Area::new(egui::Id::new("overlapping-popup-test")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(ctx, |ui| { - ui.allocate_space(egui::vec2(20.0, 20.0)); - }); + egui::Area::new(egui::Id::unique("overlapping-popup-test")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show( + ctx, + |ui| { + ui.allocate_space(egui::vec2(20.0, 20.0)); + }, + ); let response = title.as_ref().unwrap(); reaches = pointer_reaches_title(ctx, response, p); }); @@ -2011,7 +2014,7 @@ mod tests { h.run_steps(3); }; let open = |h: &Harness<'_, PhotocraftApp>| h.query_by_label_contains("About PhotoCraft").is_some(); - let field_id = egui::Id::new("help-menu-search").with("field"); + let field_id = egui::Id::unique("help-menu-search").with("field"); let help = h.get_by_label("Help").rect().center(); click(&mut h, help); assert!(open(&h), "Help opened ({what})"); @@ -2063,7 +2066,7 @@ mod tests { } h.run_steps(3); assert!(h.query_by_label_contains("About PhotoCraft").is_some(), "typing keeps Help open"); - let query: String = h.ctx.data(|d| d.get_temp(egui::Id::new("help-menu-search"))).unwrap_or_default(); + let query: String = h.ctx.data(|d| d.get_temp(egui::Id::unique("help-menu-search"))).unwrap_or_default(); assert_eq!(query, "DeSelect all", "every letter reached the field"); assert!(h.state().ui.dialogs.is_empty(), "no menu command ran: {:?}", h.state().ui.dialogs); } @@ -2144,6 +2147,7 @@ mod tests { let before = item.rect().top(); harness.hover_at(item.rect().center()); harness.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, -80.0), phase: egui::TouchPhase::Move, diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 403ab28cd1..91eb91e5d3 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -146,7 +146,7 @@ fn every_theme_shows_the_auto_select_toggle() { PhotocraftApp::setup_context(&h.ctx, theme); h.run_steps(4); assert!(h.state().ui.tool_options.move_auto_select); - h.get_by_label("Auto-Select:").click(); + h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Auto-Select:").click(); h.run_steps(2); assert!(!h.state().ui.tool_options.move_auto_select, "{theme:?}: the toggle turns Auto-Select off"); } diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index a44c738f9e..7174fd0d43 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -647,7 +647,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect(); - if widgets::dropdown(ui, "nd-unit", &mut unit, &opts, 120.0) { + if widgets::dropdown(ui, "nd-unit", tl!("Units"), &mut unit, &opts, 120.0) { f.insert("__unit".into(), json!(unit)); f.remove("__savedPreset"); } @@ -681,7 +682,8 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = DEPTH_OPTIONS.iter().map(|(bits, label, tooltip)| (*bits, *label, *tooltip)).collect(); - if widgets::dropdown_with_tooltips(ui, "nd-depth", &mut depth, &depth_options, 120.0) { + if widgets::dropdown_with_tooltips(ui, "nd-depth", tl!("Depth"), &mut depth, &depth_options, 120.0) { f.insert("depth".into(), json!(depth)); f.remove("__savedPreset"); } @@ -764,7 +773,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = opts.iter().map(|(v, l)| (v.clone(), l.as_str())).collect(); - if widgets::dropdown(ui, "nd-profile", &mut profile, &shown, 240.0) { + if widgets::dropdown(ui, "nd-profile", tl!("Color Profile"), &mut profile, &shown, 240.0) { if profile == "working" { f.remove("profile"); } else { @@ -788,7 +797,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { ui.radio_value(&mut orientation, "vertical".to_string(), tl!("Vertical")); }); ui.end_row(); - ui.label(tl!("Position")); - ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0)); + let position_label = ui.label(tl!("Position")); + ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0)).labelled_by(position_label.id); ui.end_row(); ui.label(tl!("Color")); ui.horizontal(|ui| { // Custom… first, then Photoshop's colours. let options: Vec<(String, &str)> = std::iter::once(CUSTOM).chain(GUIDE_COLORS.iter().map(|(n, ..)| *n)).map(|n| (n.to_string(), color_label(n))).collect(); - crate::widgets::dropdown(ui, "new-guide-color", &mut color, &options, 140.0); + crate::widgets::dropdown(ui, "new-guide-color", tl!("Color"), &mut color, &options, 140.0); if color == CUSTOM { let hex = custom_color(f); let rgb = photocraft_engine::prefs::parse_hex(&hex).unwrap_or([0x4a, 0xff, 0xff]); diff --git a/crates/ui-egui/src/new_layer_ui.rs b/crates/ui-egui/src/new_layer_ui.rs index af051adbb0..9f7281def0 100644 --- a/crates/ui-egui/src/new_layer_ui.rs +++ b/crates/ui-egui/src/new_layer_ui.rs @@ -72,12 +72,12 @@ pub fn neutral_label(mode: BlendMode) -> String { pub fn body(ui: &mut egui::Ui, f: &mut Map) { let label_width = 64.0; let row_label = |ui: &mut egui::Ui, text: &str| { - ui.allocate_ui_with_layout(egui::vec2(label_width, 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| ui.label(text)); + ui.allocate_ui_with_layout(egui::vec2(label_width, 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| ui.label(text)).inner }; ui.horizontal(|ui| { - row_label(ui, tl!("Name:")); + let name_label = row_label(ui, tl!("Name:")); let mut name = f.get("name").and_then(Value::as_str).unwrap_or("").to_string(); - let field = ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)); + let field = ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)).labelled_by(name_label.id); if field.changed() { f.insert("name".into(), json!(name)); } @@ -99,7 +99,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { row_label(ui, tl!("Color:")); let mut color = f.get("color").and_then(Value::as_str).and_then(LabelColor::from_id).unwrap_or_default(); let options: Vec<(LabelColor, &str)> = LabelColor::ALL.into_iter().map(|c| (c, crate::layer_menu_ui::color_name(c))).collect(); - if crate::widgets::dropdown(ui, "new-layer-color", &mut color, &options, 140.0) { + if crate::widgets::dropdown(ui, "new-layer-color", tl!("Color"), &mut color, &options, 140.0) { f.insert("color".into(), json!(color.id())); } }); @@ -107,13 +107,13 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.horizontal(|ui| { row_label(ui, tl!("Mode:")); let options: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.into_iter().map(|m| (m, m.label())).collect(); - if crate::widgets::dropdown(ui, "new-layer-mode", &mut m, &options, 140.0) { + if crate::widgets::dropdown(ui, "new-layer-mode", tl!("Mode"), &mut m, &options, 140.0) { f.insert("mode".into(), json!(m.label())); } ui.add_space(8.0); ui.label(tl!("Opacity:")); let mut o = f.get("opacity").and_then(Value::as_f64).unwrap_or(100.0) as f32; - if crate::widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 64.0).changed() { + if crate::widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 64.0).accessible_name(tl!("Opacity:")).changed() { f.insert("opacity".into(), json!(o.clamp(0.0, 100.0))); } }); diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index 79e4dad3db..52597597a8 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -221,7 +221,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = crate::theme::Tokens::get(ctx); let mut dismiss_id = None; // Clear the status bar (~24 px) and leave the dock's edge some air. - let area = egui::Area::new(egui::Id::new("photocraft-notices")) + let area = egui::Area::new(egui::Id::unique("photocraft-notices")) .order(egui::Order::Foreground) .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -36.0)) .interactable(true) diff --git a/crates/ui-egui/src/numeric_expression.rs b/crates/ui-egui/src/numeric_expression.rs index 6c7f96393c..2d2d35cb44 100644 --- a/crates/ui-egui/src/numeric_expression.rs +++ b/crates/ui-egui/src/numeric_expression.rs @@ -309,7 +309,7 @@ mod widget_tests { fn typed_expression_commits_clamps_and_rejects_invalid() { let mut h = Harness::builder().build_ui_state( |ui, value| { - ui.add(egui::DragValue::new(value).range(0.0..=1000.0).custom_parser(super::parse)); + ui.add(egui::DragValue::new(value).range(0.0..=1000.0).custom_parser(super::parse)).accessible_name("Test number"); }, 100.0_f64, ); diff --git a/crates/ui-egui/src/options_bar_mode_wheel_tests.rs b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs index 068561b4a0..69fbb33b32 100644 --- a/crates/ui-egui/src/options_bar_mode_wheel_tests.rs +++ b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs @@ -34,6 +34,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, diff --git a/crates/ui-egui/src/options_hit_area_tests.rs b/crates/ui-egui/src/options_hit_area_tests.rs index 1498307f55..9af3647016 100644 --- a/crates/ui-egui/src/options_hit_area_tests.rs +++ b/crates/ui-egui/src/options_hit_area_tests.rs @@ -1,7 +1,10 @@ //! #5052: the actual Brush options caption and field form one scrub target. use crate::PhotocraftApp; use egui::{Modifiers, Pos2, Rect, pos2, vec2}; -use egui_kittest::{Harness, kittest::Queryable}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; fn harness() -> Harness<'static, PhotocraftApp> { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -21,7 +24,13 @@ fn harness() -> Harness<'static, PhotocraftApp> { } fn geometry(h: &Harness<'_, PhotocraftApp>) -> (Rect, Rect) { - let label = h.get_by_label("Hardness").rect(); + // The visible caption, not the number field it names: kittest returns the labelled control + // for `get_by_label`, whose rect would put the drag inside the numeric editor. + let label = h + .query_all(egui_kittest::kittest::by().label("Hardness").include_labels()) + .find(|n| n.accesskit_node().role() == egui::accesskit::Role::Label) + .expect("the Hardness caption") + .rect(); let field = h .get_all_by_role(egui::accesskit::Role::SpinButton) .map(|n| n.rect()) @@ -118,7 +127,7 @@ fn options_expanded_hit_click_does_not_edit_or_request_focus() { click(&mut h, f.center()); let before_focus = h.ctx.memory(|m| m.focused()).expect("numeric editor focus"); let before_journal = h.state().session.journal.len(); - let l = h.get_by_label("Hardness").rect(); + let (l, _) = geometry(&h); click(&mut h, l.center() + vec2(0.0, 10.0)); assert_eq!(h.state().session.tools.brush.hardness, 0.5); println!( diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index 84695522d1..2bbb9d9953 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -358,7 +358,7 @@ mod tests { assert!(strokes(&h).is_empty()); // The picker shows over the canvas at the pointer, moved up just enough to stay on screen. h.run_steps(2); - let shown = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-brush-picker"))).expect("picker shown"); + let shown = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-brush-picker"))).expect("picker shown"); let screen = h.ctx.content_rect(); assert!((shown.min.x - c.x).abs() < 1.0 && shown.min.y <= c.y + 1.0, "{shown:?}"); assert!(screen.contains_rect(shown.shrink(0.5)) && shown.width() > 250.0 && shown.height() > 150.0, "{shown:?}"); @@ -449,11 +449,11 @@ mod tests { let c = h.state().last_canvas_rect.center(); h.state_mut().ui.brush_picker = Some([c.x, c.y]); h.run_steps(2); - assert!(h.query_by_label("Hardness").is_some()); + assert!(h.query_all_by_label("Hardness").next().is_some()); let sampled = h.state().session.tools.presets.iter().find(|p| p.brush.tip != photocraft_engine::paint::TipShape::Round).map(|p| p.name.clone()); h.state_mut().run("tools.setBrush", json!({ "preset": sampled.expect("a sampled preset") })).unwrap(); h.run_steps(2); - assert!(h.query_by_label("Hardness").is_none()); + assert!(h.query_all_by_label("Hardness").next().is_none()); assert!(h.state().ui.brush_picker.is_some()); } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 7de5195988..eeb01de764 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -48,7 +48,7 @@ const RECENT_CAP: usize = 8; /// The id of the palette's recently-run list (egui temp data: session-scoped, like the query). fn recent_id() -> egui::Id { - egui::Id::new("palette-recent") + egui::Id::unique("palette-recent") } /// Remember `id` as the most recently run command: deduplicated, newest first, capped. @@ -124,7 +124,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); // Scrim. - egui::Area::new(egui::Id::new("palette-scrim")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("palette-scrim")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let r = ui.allocate_exact_size(screen.size(), Sense::click()).1; ui.painter().rect_filled(screen, 0.0, Color32::from_black_alpha(110)); if r.clicked() { @@ -133,7 +133,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { }); let width = 560.0; let mut run: Option = None; - egui::Area::new(egui::Id::new("palette")) + egui::Area::new(egui::Id::unique("palette")) .order(egui::Order::Tooltip) .pivot(Align2::CENTER_TOP) .fixed_pos(egui::pos2(screen.center().x, screen.top() + 110.0)) @@ -146,7 +146,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { .inner_margin(egui::Margin::same(10)) .show(ui, |ui| { ui.set_width(width); - let qid = egui::Id::new("palette-query"); + let qid = egui::Id::unique("palette-query"); let mut q: String = ui.data_mut(|d| d.get_temp(qid).unwrap_or_default()); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); @@ -190,7 +190,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } hits.sort_by(|a, b| b.0.cmp(&a.0).then(a.2.cmp(&b.2))); hits.truncate(12); - let sel_id = egui::Id::new("palette-sel"); + let sel_id = egui::Id::unique("palette-sel"); let mut sel: usize = ui.data_mut(|d| d.get_temp(sel_id).unwrap_or(0)); let (down, up, enter, esc) = ui.input(|i| { ( @@ -247,7 +247,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { }); if let Some(id) = run { app.ui.palette_open = false; - ctx.data_mut(|d| d.insert_temp(egui::Id::new("palette-query"), String::new())); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("palette-query"), String::new())); if let Some(tool) = id.strip_prefix("tool:") { if let Some(t) = crate::state::Tool::from_name(tool) { app.ui.tool = t; diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 06971e56a0..b97e330369 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -36,10 +36,10 @@ struct LegacyDrag { } fn cancel_legacy_drag(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { - let key = egui::Id::new("photocraft-panel-docking").with("legacy-origin"); + let key = egui::Id::unique("photocraft-panel-docking").with("legacy-origin"); let previous = ctx.data_mut(|data| data.remove_temp::(key)); if let Some(previous) = previous { - craft_ui::docking::cancel_drag::(ctx, egui::Id::new("photocraft-panel-docking")); + craft_ui::docking::cancel_drag::(ctx, egui::Id::unique("photocraft-panel-docking")); if previous.generation != app.ui.docking_generation { return false; } @@ -126,6 +126,13 @@ fn collapsed_members(app: &PhotocraftApp, layout: &Layout) -> std::colle collapsed } +/// The id under which the custom dock's [`egui::containers::panel::PanelState`] was stored last +/// frame (its parent scope, which need not be the viewport's root). +#[cfg(test)] +pub(crate) fn shared_panel_state_id(ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp(egui::Id::unique("pc-shared-panel-state-id"))) +} + fn first_root(layout: &Layout, except: &str) -> Option { let root = Layout { root: layout.root.clone(), floating: Vec::new() }; root.panels().into_iter().find(|p| p.as_str() != except).cloned() @@ -365,7 +372,7 @@ fn change(app: &mut PhotocraftApp, operation: &str, params: &Value) -> Result bool { - let area = egui::Id::new("photocraft-panel-docking"); + let area = egui::Id::unique("photocraft-panel-docking"); let stale_origin = ui.ctx().data_mut(|data| data.get_temp::(area.with("legacy-origin")).is_some_and(|origin| origin.generation != app.ui.docking_generation)); let changed_workspace = ui.ctx().data_mut(|data| { @@ -421,7 +428,7 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { style.panels_label = tl!("Panels").to_string(); style.resize_label = tl!("Resize panels").to_string(); style.resize_window_label = tl!("Resize panel window").to_string(); - let mut area = craft_ui::docking::DockArea::new(egui::Id::new("photocraft-panel-docking")); + let mut area = craft_ui::docking::DockArea::new(egui::Id::unique("photocraft-panel-docking")); if let Some(panel) = &reveal { area = area.raise_panel(panel.clone()); } @@ -445,6 +452,11 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { ) }; let output = if layout.root.is_some() { + // The panel persists its size under this parent-scoped id; tests and diagnostics read it + // back from here rather than guessing the root scope. + ui.ctx().data_mut(|d| { + d.insert_temp(egui::Id::unique("pc-shared-panel-state-id"), ui.scope_id().with_salt(egui::IdSalt::new("shared-panel-dock"))); + }); let mut panel = egui::Panel::right("shared-panel-dock") .default_size(if t.pro { 290.0 } else { 300.0 }) .size_range(crate::panels::dock_width_range(ui.ctx().content_rect().width())) @@ -464,7 +476,7 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) { return app.ui.docking.is_some(); } - ui.ctx().data_mut(|data| data.remove::(egui::Id::new("photocraft-panel-docking").with("legacy-origin"))); + ui.ctx().data_mut(|data| data.remove::(egui::Id::unique("photocraft-panel-docking").with("legacy-origin"))); } if let Some(panel) = output.group_drag.as_ref() && let Some(pointer) = ui.ctx().pointer_interact_pos() @@ -497,7 +509,7 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { } pub(crate) fn legacy_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { - let suppressed = egui::Id::new("photocraft-panel-docking").with("legacy-suppressed"); + let suppressed = egui::Id::unique("photocraft-panel-docking").with("legacy-suppressed"); if ui.input(|input| input.pointer.button_pressed(egui::PointerButton::Primary)) { ui.ctx().data_mut(|data| data.remove::(suppressed)); } @@ -519,10 +531,10 @@ pub(crate) fn legacy_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str egui::pos2(bounds.left(), response.rect.top()), egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)), ); - if ensure(&mut layout, id).is_ok() && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("photocraft-panel-docking"), id.to_string(), source) { + if ensure(&mut layout, id).is_ok() && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::unique("photocraft-panel-docking"), id.to_string(), source) { ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new("photocraft-panel-docking").with("legacy-origin"), + egui::Id::unique("photocraft-panel-docking").with("legacy-origin"), LegacyDrag { generation: app.ui.docking_generation, layout: app.ui.docking.clone(), @@ -887,7 +899,7 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { target.tabs = members .into_iter() .filter_map(|panel| { - ui.ctx().read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &panel))).map(|response| (panel, response.rect)) + ui.ctx().read_response(egui::Id::unique("photocraft-panel-docking").with(("tab", &panel))).map(|response| (panel, response.rect)) }) .collect(); } diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 71916158f8..e77626d313 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -116,8 +116,8 @@ fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab drag(&mut h, source, egui::pos2(160.0, 140.0)); let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking"); assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); - let source = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); - let destination = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + let source = h.ctx.read_response(egui::Id::unique("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + let destination = h.ctx.read_response(egui::Id::unique("photocraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); drag(&mut h, source, destination); let layout = h.state().ui.docking.as_ref().unwrap(); assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into()))); @@ -134,8 +134,8 @@ fn brushes_tab_drag_floats_and_can_redock_into_another_tab_group() { let layout = h.state().ui.docking.as_ref().expect("brushes drag starts shared docking"); assert!(layout.floating.iter().any(|group| group.panels.contains(&"brushes".into())), "{layout:?}"); - let source = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"brushes".to_string()))).unwrap().rect.center(); - let destination = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + let source = h.ctx.read_response(egui::Id::unique("photocraft-panel-docking").with(("tab", &"brushes".to_string()))).unwrap().rect.center(); + let destination = h.ctx.read_response(egui::Id::unique("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); drag(&mut h, source, destination); let layout = h.state().ui.docking.as_ref().unwrap(); assert!(!layout.floating.iter().any(|group| group.panels.contains(&"brushes".into()))); @@ -231,7 +231,7 @@ fn floating_group_titles_are_opaque_in_studio_themes() { app, ); h.run_steps(5); - let area = h.ctx.memory(|m| m.area_rect(egui::Id::new("photocraft-panel-docking").with(("floating", &"swatches".to_string())))).unwrap(); + let area = h.ctx.memory(|m| m.area_rect(egui::Id::unique("photocraft-panel-docking").with(("floating", &"swatches".to_string())))).unwrap(); let image = h.render().unwrap(); let ppp = image.width() as f32 / 1280.0; let strip = egui::Rect::from_min_size(area.min, vec2(area.width(), 40.0)).shrink(2.0); @@ -379,7 +379,7 @@ fn populated_canvas_keeps_a_real_opacity_edit_after_floating_and_redocking() { ); h.input_mut().max_texture_side = Some(8192); h.run_steps(5); - let floating = h.ctx.memory(|m| m.area_rect(egui::Id::new("photocraft-panel-docking").with(("floating", &"properties".to_string())))).unwrap(); + let floating = h.ctx.memory(|m| m.area_rect(egui::Id::unique("photocraft-panel-docking").with(("floating", &"properties".to_string())))).unwrap(); let mut sliders: Vec<_> = h.ctx.viewport(|vp| { vp.prev_pass .widgets @@ -404,8 +404,8 @@ fn populated_canvas_keeps_a_real_opacity_edit_after_floating_and_redocking() { std::fs::create_dir_all(directory).unwrap(); h.render().unwrap().save(directory.join("photocraft-populated-opacity-floating.png")).unwrap(); } - let from = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); - let to = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + let from = h.ctx.read_response(egui::Id::unique("photocraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + let to = h.ctx.read_response(egui::Id::unique("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); drag(&mut h, from, to); assert!(h.state().ui.docking.as_ref().unwrap().floating.iter().all(|group| !group.panels.iter().any(|panel| panel == "properties"))); assert_eq!(h.state().session.active().unwrap().doc.layer(layer).unwrap().opacity, opacity); @@ -462,7 +462,7 @@ fn first_legacy_drag_workspace_switch_preserves_custom_and_legacy_destinations() // One frame installs the first legacy drag; no shared dock has rendered yet. h.run_steps(1); assert!(h.state().ui.docking.is_some()); - let origin = egui::Id::new("photocraft-panel-docking").with("legacy-origin"); + let origin = egui::Id::unique("photocraft-panel-docking").with("legacy-origin"); assert!(h.ctx.data_mut(|data| data.get_temp::(origin)).unwrap().layout.is_none()); let ctx = h.ctx.clone(); let command = if customized_destination { "window.workspace.select" } else { "window.workspace.painting" }; @@ -566,7 +566,8 @@ fn legacy_dock_width_is_consumed_by_custom_renderer_and_clamped_to_existing_rang for (requested, expected) in [(410.0, 410.0), (700.0, 700.0), (900.0, 880.0), (20.0, 250.0)] { assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":requested}))["ok"], true); h.run_steps(2); - let state = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-dock")).unwrap(); + let key = crate::panel_docking::shared_panel_state_id(&h.ctx).unwrap_or_else(|| crate::panels::panel_state_id(&h.ctx, "shared-panel-dock")); + let state = egui::containers::panel::PanelState::load(&h.ctx, key).expect("the custom dock persisted its size"); assert!((state.outer_rect.width() - expected).abs() < 1.0, "{} / {expected}", state.outer_rect.width()); assert!(h.ctx.data(|data| data.get_temp::(crate::panels::dock_width_id())).is_none()); } @@ -649,7 +650,7 @@ fn customized_panel_menus_and_rail_remain_reachable_after_float_and_restore() { let mut h = harness(app, vec2(1280.0, 800.0)); assert!(h.query_all_by_label("Panel menu").count() > 0); assert!(h.query_all_by_label("Color & Swatches").count() > 0); - let floating = h.ctx.memory(|memory| memory.area_rect(egui::Id::new("photocraft-panel-docking").with(("floating", &"swatches".to_string())))).unwrap(); + let floating = h.ctx.memory(|memory| memory.area_rect(egui::Id::unique("photocraft-panel-docking").with(("floating", &"swatches".to_string())))).unwrap(); let first = h.query_all_by_label("Panel menu").find(|node| floating.contains_rect(node.rect())).unwrap().rect().center(); h.hover_at(first); for pressed in [true, false] { @@ -682,7 +683,7 @@ fn floated_color_panel_menu_picks_the_mode() { let mut app = app(); app.run("window.panel.float", json!({"panel":"color","x":20,"y":40})).unwrap(); let mut h = harness(app, vec2(1280.0, 800.0)); - let floating = h.ctx.memory(|memory| memory.area_rect(egui::Id::new("photocraft-panel-docking").with(("floating", &"color".to_string())))).unwrap(); + let floating = h.ctx.memory(|memory| memory.area_rect(egui::Id::unique("photocraft-panel-docking").with(("floating", &"color".to_string())))).unwrap(); let button = h.query_all_by_label("Panel menu").find(|node| floating.contains_rect(node.rect())).unwrap().rect().center(); h.hover_at(button); for pressed in [true, false] { @@ -757,8 +758,8 @@ fn floating_group_grip_redocks_all_tabs_as_one_atomic_move() { app.run("window.panel.float", json!({"panel":"swatches","x":30,"y":60})).unwrap(); app.run("window.panel.move", json!({"panel":"patterns","anchor":"swatches"})).unwrap(); let mut h = harness(app, vec2(1280.0, 800.0)); - let from = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("move-group", Some(&"swatches".to_string())))).unwrap().rect.center(); - let to = h.ctx.read_response(egui::Id::new("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + let from = h.ctx.read_response(egui::Id::unique("photocraft-panel-docking").with(("move-group", Some(&"swatches".to_string())))).unwrap().rect.center(); + let to = h.ctx.read_response(egui::Id::unique("photocraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); drag(&mut h, from, to); let layout = h.state().ui.docking.as_ref().unwrap(); assert!(layout.floating.iter().all(|group| !group.panels.iter().any(|id| id == "swatches" || id == "patterns"))); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bcb59b5e90..e79f746242 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -11,6 +11,103 @@ use crate::state::Tool; use crate::theme::{self, Tokens}; use crate::{PhotocraftApp, icons, widgets}; +// ----------------------------------------------------------------------------- panel state + +/// The persisted [`egui::PanelState`] key for the panel named `salt` in this context's root UI. +/// +/// egui 0.37 derives a panel's id from its parent [`egui::Ui`]: +/// `panel_ui.scope_id().with_salt(salt)`. For the root Ui that is +/// `ctx.viewport_id().root_ui_id().with(salt)`, whereas 0.36 stored the same panels under +/// `Id::new(salt)` (`Id::unique(salt)` has the same hash). Use this in tests and migrations; never +/// substitute [`egui::Ui::unique_id`], which is unstable. +pub fn panel_state_id(ctx: &egui::Context, salt: impl egui::AsIdSalt) -> egui::Id { + ctx.viewport_id().root_ui_id().with(salt) +} + +/// The panels whose saved sizes moved to a parent-scoped key in 0.37. +const PANEL_STATE_KEYS: &[&str] = &["dock", "title_bar", "options_bar", "status_bar", "toolbar", "rail", "shared-panel-dock"]; + +/// What the two keys held when this build last looked at a panel, so a later startup can tell +/// which one a 0.36 rollback or a 0.37 session wrote last. +#[derive(Clone, Copy, serde::Serialize, serde::Deserialize)] +struct PanelStamp { + old: [f32; 4], + new: [f32; 4], +} + +fn rect_key(state: egui::containers::panel::PanelState) -> [f32; 4] { + let r = state.outer_rect; + [r.min.x, r.min.y, r.max.x, r.max.y] +} + +fn stamp_id(name: &str) -> egui::Id { + egui::Id::unique(format!("photocraft-panel-state-stamp:{name}")) +} + +/// Copy 0.36 `PanelState` entries to their 0.37 parent-scoped keys. +/// +/// The old `Id::unique(name)` entry is left in place so a 0.36 build still reads the same saved +/// widths. On a later startup, a stamp of both keys decides which one is newer: if a 0.36 build +/// resized the panel after the migration (the old key differs from the stamp, the new one does +/// not), the old key wins; a 0.37 resize (the new key differs) is kept. If both changed the order +/// is unknown; the new key is kept, so a 0.37 resize *after* a 0.36 rollback resize can still be +/// lost (documented in the plan's rollback section). Data-only: call it before any panel is shown. +pub fn migrate_panel_state(ctx: &egui::Context) { + use egui::containers::panel::PanelState; + let done = egui::Id::unique("photocraft-panel-state-migrated"); + if ctx.data(|d| d.get_temp::(done).unwrap_or(false)) { + return; + } + for name in PANEL_STATE_KEYS { + let old_id = egui::Id::unique(*name); + let new_id = panel_state_id(ctx, *name); + let id = stamp_id(name); + let old = PanelState::load(ctx, old_id); + let new = PanelState::load(ctx, new_id); + let stamp = ctx.data_mut(|d| d.get_persisted::(id)); + let Some(old) = old else { continue }; + let old_key = rect_key(old); + match (new, stamp) { + (None, _) => { + // First migration: copy, and remember what both keys held. + ctx.data_mut(|d| d.insert_persisted(new_id, old)); + ctx.data_mut(|d| d.insert_persisted(id, PanelStamp { old: old_key, new: old_key })); + } + (Some(new), None) => { + // A partial migration or a pre-existing 0.37 value: keep it and start stamping. + ctx.data_mut(|d| d.insert_persisted(id, PanelStamp { old: old_key, new: rect_key(new) })); + } + (Some(new), Some(stamp)) => { + let old_changed = old_key != stamp.old; + let new_changed = rect_key(new) != stamp.new; + if old_changed && !new_changed { + // A 0.36 rollback resized the panel after the migration: its key is newer. + ctx.data_mut(|d| d.insert_persisted(new_id, old)); + ctx.data_mut(|d| d.insert_persisted(id, PanelStamp { old: old_key, new: old_key })); + } else if new_changed { + ctx.data_mut(|d| d.insert_persisted(id, PanelStamp { old: old_key, new: rect_key(new) })); + } + } + } + } + ctx.data_mut(|d| d.insert_temp(done, true)); +} + +/// Remember the 0.37 panel sizes this session ends a pass with, so a later 0.36 rollback's newer +/// old-key values can be recognized. Cheap when nothing changed (no write). +pub fn remember_panel_state(ctx: &egui::Context) { + use egui::containers::panel::PanelState; + for name in PANEL_STATE_KEYS { + let Some(new) = PanelState::load(ctx, panel_state_id(ctx, *name)) else { continue }; + let id = stamp_id(name); + let Some(stamp) = ctx.data_mut(|d| d.get_persisted::(id)) else { continue }; + let new_key = rect_key(new); + if new_key != stamp.new { + ctx.data_mut(|d| d.insert_persisted(id, PanelStamp { old: stamp.old, new: new_key })); + } + } +} + // ----------------------------------------------------------------------------- toolbar /// Tool groups separated by hairlines, Photoshop order. @@ -107,7 +204,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click()); let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") }; icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Toolbar"))); if resp.clicked() { app.ui.panels.toolbar_double = !double; } @@ -128,7 +225,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().add(mesh); } ui.spacing_mut().item_spacing = vec2(2.0, 3.0); - let flyout_id = egui::Id::new("tool-flyout"); + let flyout_id = egui::Id::unique("tool-flyout"); let held_id = flyout_id.with("held"); if ui.input(|i| i.pointer.any_pressed()) { ui.data_mut(|d| d.remove::(held_id)); @@ -165,7 +262,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { for row in rows { ui.horizontal(|ui| { for (slot_index, slot) in row.iter() { - let key = egui::Id::new(("tool-slot", *slot_index)); + let key = egui::Id::unique(("tool-slot", *slot_index)); let tool = slot_tool(ui, app.ui.tool, slot, key); let sel = slot.contains(&app.ui.tool); let tip = match crate::tool_shortcuts::shortcut(app, tool) { @@ -485,8 +582,8 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool, co frame(fg); } // Photoshop: clicking a chip opens the Color Picker for that colour. - let bg_resp = ui.interact(bg, ui.id().with("bgchip"), Sense::click()); - let fg_resp = ui.interact(fg, ui.id().with("fgchip"), Sense::click()); + let bg_resp = ui.interact(bg, ui.scope_id().with("bgchip"), Sense::click()); + let fg_resp = ui.interact(fg, ui.scope_id().with("fgchip"), Sense::click()); if fg_resp.on_hover_text(tl!("Set foreground color")).clicked() { crate::color_picker_ui::open(app, "foreground"); } else if bg_resp.on_hover_text(tl!("Set background color")).clicked() { @@ -512,8 +609,8 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool, co let icon = |x: f32| Rect::from_min_size(pos2(x, rect.top()), vec2(CHIP_ICON, CHIP_ICON)); (Some(icon(left)), icon(left + group - CHIP_ICON)) }; - let d = dr.map(|dr| ui.interact(dr, ui.id().with("default-colors"), Sense::click())); - let s = ui.interact(sr, ui.id().with("swap-colors"), Sense::click()); + let d = dr.map(|dr| ui.interact(dr, ui.scope_id().with("default-colors"), Sense::click())); + let s = ui.interact(sr, ui.scope_id().with("swap-colors"), Sense::click()); let p = ui.painter(); for (r, resp) in dr.into_iter().zip(d.iter()).chain([(sr, &s)]) { if resp.hovered() && !studio { @@ -563,9 +660,9 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool, co p.rect_stroke(b, 1.0, Stroke::new(1.0, ink(d)), StrokeKind::Inside); } if let Some(d) = &d { - d.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Default colours (D)"))); + d.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Default colours (D)"))); } - s.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Swap colours (X)"))); + s.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Swap colours (X)"))); if s.on_hover_text(tl!("Swap colours (X)")).clicked() { chip_command(app, "tools.swapColors"); } @@ -611,11 +708,11 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Only the free gap between the menus and the far-end controls drags the window: a // press on a menu title must open the menu, never move the window. The gap is last // frame's, as the menus are laid out after this. - let span_id = ui.id().with("titledrag-span"); + let span_id = ui.scope_id().with("titledrag-span"); let (gap_l, gap_r) = ui.ctx().data(|d| d.get_temp::<(f32, f32)>(span_id)).unwrap_or((full.right(), full.right())); let gap = egui::Rect::from_x_y_ranges(gap_l.max(full.left())..=gap_r.min(full.right()).max(gap_l), full.y_range()); ui.ctx().data_mut(|d| d.insert_temp(crate::titlebar::modal_drag_area_id(), gap)); - let drag = ui.interact(gap, ui.id().with("titledrag"), Sense::click_and_drag()); + let drag = ui.interact(gap, ui.scope_id().with("titledrag"), Sense::click_and_drag()); if drag.drag_started_by(egui::PointerButton::Primary) { crate::titlebar::start_os_move(ui.ctx()); } @@ -684,7 +781,7 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 0 }; let ws_width = (room - f32::from(icons_shown) * ICON_SLOT - 6.0).clamp(WORKSPACE_MIN, 130.0); - if room >= WORKSPACE_MIN && widgets::dropdown(ui, "workspace", &mut ws, &opts, ws_width) { + if room >= WORKSPACE_MIN && widgets::dropdown(ui, "workspace", tl!("Workspace"), &mut ws, &opts, ws_width) { app.ui.workspace = ws; crate::menus::apply_workspace(app); } @@ -938,7 +1035,7 @@ fn reveal_focused_control(ui: &egui::Ui) { return; } let focused = ui.memory(|memory| memory.focused()); - let key = ui.id().with("scroll-focused-control"); + let key = ui.scope_id().with("scroll-focused-control"); let changed = ui.data_mut(|data| { let previous = data.get_temp::>(key).flatten(); data.insert_temp(key, focused); @@ -1195,7 +1292,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ("fixedRatio".to_string(), tl!("Fixed Ratio")), ("fixedSize".to_string(), tl!("Fixed Size")), ]; - if ui.add_enabled_ui(sized, |ui| widgets::dropdown(ui, "marquee-style", &mut o.marquee_style, &styles, 96.0)).inner { + if ui + .add_enabled_ui(sized, |ui| widgets::dropdown(ui, "marquee-style", tl!("Style"), &mut o.marquee_style, &styles, 96.0)) + .inner + { (o.marquee_width, o.marquee_height) = if o.marquee_style == "fixedSize" { (64.0, 64.0) } else { (1.0, 1.0) }; } let fixed = sized && o.marquee_style != "normal"; @@ -1289,7 +1389,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { Tool::PaintBucket => { opt_label(ui, tl!("Fill")); let mut src = u8::from(app.ui.tool_options.bucket_fill_pattern); - if widgets::dropdown(ui, "bucket-src", &mut src, &[(0u8, tl!("Foreground")), (1, tl!("Pattern"))], 100.0) { + if widgets::dropdown(ui, "bucket-src", tl!("Source"), &mut src, &[(0u8, tl!("Foreground")), (1, tl!("Pattern"))], 100.0) { app.ui.tool_options.bucket_fill_pattern = src == 1; } opt_label(ui, tl!("Opacity")); @@ -1303,7 +1403,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { Tool::Gradient => { // Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels). let mut classic = app.ui.tool_options.gradient_classic; - if widgets::dropdown(ui, "gradient-mode", &mut classic, &[(false, "Gradient"), (true, "Classic gradient")], 118.0) { + if widgets::dropdown(ui, "gradient-mode", tl!("Mode"), &mut classic, &[(false, "Gradient"), (true, "Classic gradient")], 118.0) + { app.ui.tool_options.gradient_classic = classic; } // Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels): @@ -1331,7 +1432,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { opt_label(ui, tl!("Mode")); let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); // The wheel over it steps the mode, as over the Layers panel's (#2061). - widgets::dropdown_wheel_hovered(ui, "gradient-blend-mode", &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0); + widgets::dropdown_wheel_hovered( + ui, + "gradient-blend-mode", + tl!("Blend Mode"), + &mut app.ui.tool_options.gradient_blend_mode, + &opts, + 96.0, + ); opt_label(ui, tl!("Opacity")); let r = widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); // Live while scrubbed, one undo step per scrub (#5012). @@ -1376,7 +1484,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } // Every ratio typed or scrubbed in one edit fits from the frame the edit // started with: 1:3 on the way to 16:3 mustn't shrink it first (#7752). - let base_id = egui::Id::new("crop-ratio-edit-base"); + let base_id = egui::Id::unique("crop-ratio-edit-base"); if cw || ch { ratio_base = ui.data(|d| d.get_temp::<[f64; 4]>(base_id)).or(app.ui.crop_rect); } @@ -1404,6 +1512,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::dropdown( ui, "crop-fill", + tl!("Fill:"), &mut o.crop_content_aware, &[(false, tl!("Background (Default)")), (true, tl!("Content-Aware Fill"))], 150.0, @@ -1442,6 +1551,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::dropdown( ui, "move-target", + tl!("Auto-Select:"), &mut o.move_target, &[("layer".to_string(), tl!("Layer")), ("group".to_string(), tl!("Group"))], 76.0, @@ -1646,7 +1756,7 @@ fn fixed_size_field(ui: &mut egui::Ui, px: &mut f32, units: widgets::FieldUnits, fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) { opt_label(ui, label); let mut percent = *value * 100.0; - if widgets::value_field(ui, &mut percent, range, "%", width).changed() { + if widgets::value_field_named(ui, label, &mut percent, range, "%", width).changed() { *value = percent / 100.0; } } @@ -1688,6 +1798,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { (st.doc.size.width, st.doc.size.height, crate::canvas::mode_label(&st.doc), st.doc.depth.bits(), st.doc.layer_count()); let mut pct = app.ui.views[i].zoom * 100.0; if widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(78.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); @@ -1794,7 +1905,7 @@ pub(crate) fn dock_width_range(window_width: f32) -> std::ops::RangeInclusive egui::Id { - egui::Id::new("dock-width-request") + egui::Id::unique("dock-width-request") } /// Set the right dock's width on the next frame (`ui.set {dockWidth}`); clamped to its range. @@ -2065,7 +2176,7 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures; if resp.hovered() && let Some(crate::wheel_nav::Wheel::Zoom(factor)) = - crate::wheel_nav::read_with(&ctx, true, pinch_zooms, egui::Id::new("pc-navigator-wheel"), true, true, touch_gestures) + crate::wheel_nav::read_with(&ctx, true, pinch_zooms, egui::Id::unique("pc-navigator-wheel"), true, true, touch_gestures) { let view = &mut app.ui.views[idx]; let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size); @@ -2103,7 +2214,8 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.views[idx].fit_pending = false; }; let mut pct = v.zoom * 100.0; - let field = widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[idx]), "%", vec2(64.0, NAV_ROW_H)); + let field = widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[idx]), "%", vec2(64.0, NAV_ROW_H)) + .accessible_name(tl!("Zoom")); if field.changed() { set(app, pct / 100.0, false); } @@ -2147,7 +2259,7 @@ fn navigator_fit(frame: Rect, w: u32, h: u32) -> Rect { /// Where a ⌘/Ctrl-drag over the Navigator's preview started, while it lasts. fn navigator_zoom_drag_id() -> egui::Id { - egui::Id::new("navigator-zoom-drag") + egui::Id::unique("navigator-zoom-drag") } /// Zoom `view` so the document rectangle `doc_rect` (pixels) fills a canvas of `canvas` points, @@ -2183,15 +2295,15 @@ fn navigator_zoom_to(view: &mut crate::state::View, doc_rect: Rect, doc: [u32; 2 } fn navigator_preview_id() -> egui::Id { - egui::Id::new("navigator-preview-rect") + egui::Id::unique("navigator-preview-rect") } fn navigator_controls_id() -> egui::Id { - egui::Id::new("navigator-controls-height") + egui::Id::unique("navigator-controls-height") } fn navigator_controls_rect_id() -> egui::Id { - egui::Id::new("navigator-controls-rect") + egui::Id::unique("navigator-controls-rect") } /// Where the Navigator drew its document preview last frame. @@ -2261,7 +2373,13 @@ mod navigator_wheel_tests { fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, pointer: Pos2, wheel: f32) { let mut events = vec![Event::PointerMoved(pointer)]; if wheel != 0.0 { - events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, wheel), phase: TouchPhase::Move, modifiers: Modifiers::NONE }); + events.push(Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, + unit: MouseWheelUnit::Line, + delta: vec2(0.0, wheel), + phase: TouchPhase::Move, + modifiers: Modifiers::NONE, + }); } let mut out = ctx .run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(430.0, 460.0))), events, ..Default::default() }, |ui| navigator(app, ui)); @@ -2503,7 +2621,7 @@ fn inactive_blend_rows(ui: &mut egui::Ui, pro: bool) { let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); let mut m = BlendMode::Normal; let options = blend_options(false, m); - widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &options, blend_width); + widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &options, blend_width); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut o = 100.0; widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); @@ -2597,7 +2715,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") }; let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); - let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(all_groups, l.blend), blend_width); + let (chosen, hovered) = + widgets::dropdown_wheel_hovered(ui, "blend", tl!("Blend Mode"), &mut m, &blend_options(all_groups, l.blend), blend_width); // One step per choice: a click, an arrow key or each wheel notch (#1747). for m in &chosen { let mut p = targets.clone(); @@ -2703,7 +2822,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ctx.data_mut(|d| d.remove::(crate::smart_ui::drag_key())); } let held = ctx.data(|d| { - d.get_temp::(egui::Id::new("layer-drag")).is_some() + d.get_temp::(egui::Id::unique("layer-drag")).is_some() || d.get_temp::(fx_drag_key()).is_some() || d.get_temp::(crate::smart_ui::drag_key()).is_some() }); @@ -2757,7 +2876,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // A click on that empty space deselects every layer, as in Photoshop (#8100). It // starts under the last layer's sub-rows, which take their own clicks. let empty = Rect::from_min_max(pos2(below.left(), rows_bottom), below.max); - if empty.height() > 0.0 && ui.interact(empty, ui.id().with("layers-empty-space"), Sense::click()).clicked() && !selection.is_empty() { + if empty.height() > 0.0 && ui.interact(empty, ui.scope_id().with("layers-empty-space"), Sense::click()).clicked() && !selection.is_empty() { actions.push(("select.deselectLayers".into(), json!({}))); } } @@ -2791,7 +2910,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } // A layer being dragged can also be dropped on the footer's Delete, New Layer and Group // buttons (#736); read the drag before it ends. - let footer_drag = ctx.data(|d| d.get_temp::(egui::Id::new("layer-drag"))).map(|id| { + let footer_drag = ctx.data(|d| d.get_temp::(egui::Id::unique("layer-drag"))).map(|id| { let in_selection = selection.iter().any(|s| s.0 == id); (id, in_selection) }); @@ -2810,7 +2929,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } // End any layer drag after every row has had a chance to accept the drop. if ctx.input(|i| i.pointer.any_released()) { - ctx.data_mut(|d| d.remove::(egui::Id::new("layer-drag"))); + ctx.data_mut(|d| d.remove::(egui::Id::unique("layer-drag"))); ctx.data_mut(|d| d.remove::(fx_drag_key())); ctx.data_mut(|d| d.remove::(mask_drag_key())); ctx.data_mut(|d| d.remove::(crate::smart_ui::drag_key())); @@ -2854,7 +2973,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } // A click follows the selected thumbnail; name that action for screen readers even // though dropping a whole layer row onto the same button always deletes the layer. - trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(label))); + trash.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(label))); let new_layer = icons::button(ui, "square-plus", 26.0, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer")); if new_layer.clicked() { // A click makes the layer above the selection; ⌥-click asks first (the New Layer @@ -2905,7 +3024,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::i18n::fmt(tl!("Add a mask (from the selection; {key} inverts)"), &[("key", &crate::shortcuts::pretty("Alt"))]) }; let mask_btn = ui.add_enabled_ui(mask_cmd.is_some(), |ui| icons::button(ui, "layer-mask", 26.0, false, &mask_tip)).inner; - mask_btn.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &mask_tip)); + mask_btn.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &mask_tip)); if mask_btn.clicked() && let Some(cmd) = mask_cmd { @@ -2981,7 +3100,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// returns the command on release. fn footer_drop(ui: &egui::Ui, button: &egui::Response, drag: Option<(u64, bool)>, command: &str) -> Option<(String, Value)> { // Named for screen readers (and tests) after the command a drop runs. - button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, footer_label(command))); + button.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, footer_label(command))); let (layer, in_selection) = drag?; dropped_on(ui, button).then(|| (command.into(), if in_selection { json!({}) } else { json!({"layer": layer}) })) } @@ -3029,7 +3148,7 @@ fn footer_menu_arrow(ui: &egui::Ui, rect: Rect) { fn footer_menu_button(ui: &mut egui::Ui, icon: &str, size: f32, tip: &str) -> egui::Response { let response = icons::button(ui, icon, size, false, tip); // Named for screen readers (and tests) after its tooltip. - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); footer_menu_arrow(ui, response.rect); response } @@ -3046,7 +3165,7 @@ fn fx_button(ui: &mut egui::Ui, size: f32, tip: &str) -> egui::Response { let g = ui.painter().layout_job(job); ui.painter().galley(r.center() - g.size() / 2.0, g, t.icon); footer_menu_arrow(ui, r); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); resp.on_hover_text(tip) } @@ -3070,7 +3189,7 @@ pub(crate) fn thumb_load_badge(m: egui::Modifiers) -> Option) { - let id = egui::Id::new("layer-eye-sweep"); + let id = egui::Id::unique("layer-eye-sweep"); let set = |visible: bool, key: u64| ("layer.setProps".to_string(), json!({"layer": l.id.0, "visible": visible, "coalesce": format!("layer-eye-sweep:{key}")})); if eye.drag_started() { @@ -3170,7 +3289,7 @@ fn layer_row( crate::layer_transfer::begin_from_panel(app, ctx, l.id); } // Rows are painted: name them for screen readers and UI tests. - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &l.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &l.name)); // A row scrolled out of view only keeps its place (#125): a layout's hundreds of rows would // otherwise lay out names, icons and thumbnails every frame. Group rows stay whole: their // disclosure triangle is a widget (accessibility, scroll-to). @@ -3208,7 +3327,7 @@ fn layer_row( let mut x = rect.left() + 6.0; // The whole eye column takes drags too (so a drag starting on it never reorders the row): dragging down // the eyes gives every row swept over the visibility the first eye toggled to (#8467). - let eye_resp = ui.interact(eye_cell, ui.id().with(("eye", l.id.0)), Sense::click_and_drag()); + let eye_resp = ui.interact(eye_cell, ui.scope_id().with(("eye", l.id.0)), Sense::click_and_drag()); // A hidden layer's eye box is left empty (still clickable; the whole column takes the click, #8467). if l.visible { // Centre the drawing in the column while preserving the visibility hit area. @@ -3275,14 +3394,14 @@ fn layer_row( // Dragging the fx badge drags all the layer's effects, not the layer (Photoshop). Like the eye, // it takes the drag from the row; clicks still reach the row. if let Some(&(_, badge)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Fx) - && ui.interact(badge, ui.id().with(("fx-badge", l.id.0)), Sense::drag()).drag_started() + && ui.interact(badge, ui.scope_id().with(("fx-badge", l.id.0)), Sense::drag()).drag_started() { start_fx_drag(ctx, l.id, None); } // Clicking the lock icon unlocks the layer, and makes the Background a normal one (Photoshop, // #4723). It takes the click from the row, so the row isn't selected. if let Some(&(_, lock)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Lock) - && ui.interact(lock, ui.id().with(("row-lock", l.id.0)), Sense::click()).clicked() + && ui.interact(lock, ui.scope_id().with(("row-lock", l.id.0)), Sense::click()).clicked() { actions.push(simple_lock_toggle(doc.is_background(l.id), l, &json!({"layer": l.id.0}))); } @@ -3425,7 +3544,7 @@ fn layer_row( } // The visibility icon has its own color menu, and the layer-mask thumbnail its mask menu // (#4575); elsewhere use the full layer menu. - let on_mask_id = ui.id().with(("mask-menu", l.id.0)); + let on_mask_id = ui.scope_id().with(("mask-menu", l.id.0)); if resp.secondary_clicked() { let on_mask = resp.interact_pointer_pos().and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Pixel); ctx.data_mut(|d| d.insert_temp(on_mask_id, on_mask)); @@ -3548,7 +3667,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Follow the current state when the history moves (a new step, Undo, a state row) or another // document comes up (`spot`), scrolling only if its row is hidden (#5909). Other changes (a // layer click, a channel eye) and a selected snapshot leave the list where the user put it. - let follow_id = egui::Id::new("history-follow"); + let follow_id = egui::Id::unique("history-follow"); let moved = ui.ctx().data_mut(|d| { let moved = d.get_temp::<(photocraft_doc::DocId, HistorySpot)>(follow_id).as_ref() != Some(&spot); d.insert_temp(follow_id, spot); @@ -3566,7 +3685,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Past states can be dragged onto the trash; the oldest one can't be deleted. let sense = if is_redo || i == 0 { Sense::click() } else { Sense::click_and_drag() }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), sense); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &e)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &e)); if resp.drag_started() { ui.ctx().data_mut(|d| d.insert_temp(history_drag_key(), HistoryDrag::State(i))); } @@ -3597,7 +3716,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if is_redo { t.text_faint } else { t.icon }, ); ui.painter().text(pos2(rect.left() + 58.0, rect.center().y), Align2::LEFT_CENTER, &e, egui::FontId::proportional(12.0), color); - if history_source_column(ui, rect, egui::Id::new(("history-source", i)), source == Some(HistorySource::State(i))) { + if history_source_column(ui, rect, egui::Id::unique(("history-source", i)), source == Some(HistorySource::State(i))) { set_source = Some(i); } if resp.clicked() && i != current { @@ -3696,7 +3815,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { fn history_source_column(ui: &mut egui::Ui, row: Rect, salt: egui::Id, is_source: bool) -> bool { let t = Tokens::get(ui.ctx()); let cell = Rect::from_min_size(row.min, vec2(24.0, row.height())); - let resp = ui.interact(cell, ui.id().with(salt), Sense::click()).on_hover_text(tl!("Sets the source for the history brush")); + let resp = ui.interact(cell, ui.scope_id().with(salt), Sense::click()).on_hover_text(tl!("Sets the source for the history brush")); if is_source { icons::paint(ui, cell.shrink(4.0), icons::tool_icon(Tool::HistoryBrush), 12.0, t.icon); } else if resp.hovered() { @@ -3713,7 +3832,7 @@ enum HistoryDrag { } fn history_drag_key() -> egui::Id { - egui::Id::new("history-drag") + egui::Id::unique("history-drag") } /// The snapshot the History panel shows selected (#5324), and where the history stood after its @@ -3729,7 +3848,7 @@ fn history_spot(st: &photocraft_engine::DocState) -> HistorySpot { } fn selected_snapshot_key(doc: photocraft_doc::DocId) -> egui::Id { - egui::Id::new(("history-snapshot-selected", doc)) + egui::Id::unique(("history-snapshot-selected", doc)) } /// Select snapshot `id` at the history's current state, as a click on its row does. @@ -3762,14 +3881,14 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option = snaps.iter().map(|s| s.id).collect(); // Per document: snapshot ids restart in each, and a tab switch must not carry the edit over. - let rename_id = egui::Id::new(("history-snapshot-rename", doc_id)); + let rename_id = egui::Id::unique(("history-snapshot-rename", doc_id)); let mut renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)).flatten(); let mut cmd = None; let ctx = ui.ctx().clone(); for snap in &snaps { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click_and_drag()); let is_selected = selected == Some(snap.id); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, is_selected, &snap.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, is_selected, &snap.name)); if resp.drag_started() { ctx.data_mut(|d| d.insert_temp(history_drag_key(), HistoryDrag::Snapshot(snap.id))); } @@ -3786,7 +3905,7 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option { let field = Rect::from_min_max(pos2(thumb.right() + 6.0, rect.center().y - 10.0), pos2(rect.right() - 6.0, rect.center().y + 10.0)); - let r = ui.put(field, egui::TextEdit::singleline(text)); + let r = ui.put(field, egui::TextEdit::singleline(text)).accessible_name(tl!("Name")); if r.lost_focus() { if !ui.input(|i| i.key_pressed(egui::Key::Escape)) { cmd = Some(("history.renameSnapshot", json!({"snapshot": snap.id, "name": text.clone()}))); @@ -3807,7 +3926,7 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option egui::Id { - egui::Id::new("color-field-fill-height") + egui::Id::unique("color-field-fill-height") } /// The Color field stretches to its dock group's height instead of a fixed 120 pt, in every theme @@ -4150,7 +4269,7 @@ pub(crate) fn field_hsva(mut hsva: egui::ecolor::Hsva, remembered: (f32, f32)) - pub(crate) fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let bg_active = app.ui.color_panel.background; - let key = egui::Id::new(("color-field-hue", bg_active)); + let key = egui::Id::unique(("color-field-hue", bg_active)); let mut hsva = srgb_hsva(if bg_active { app.session.tools.background } else { app.session.tools.foreground }); // What the field showed last: a grey carries no hue, black neither hue nor saturation (#3228). let remembered: (f32, f32) = ui.data(|d| d.get_temp(key)).unwrap_or((hsva.h, hsva.s)); @@ -4225,7 +4344,7 @@ pub(crate) fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(6.0); ui.horizontal(|ui| { let mut c = if bg_active { app.session.tools.background } else { app.session.tools.foreground }; - if color_readout(ui, ui.id().with(("color-field", bg_active)), &mut c) { + if color_readout(ui, ui.scope_id().with(("color-field", bg_active)), &mut c) { if bg_active { app.session.tools.background = c; } else { @@ -4306,9 +4425,9 @@ pub(crate) fn color_readout(ui: &mut egui::Ui, id: egui::Id, color: &mut [f32; 4 changed = hex_edit(ui, id.with("hex"), color); let [mut r, mut g, mut b] = srgb_bytes(*color).map(f32::from); let mut rgb = false; - for (label, v) in [("R", &mut r), ("G", &mut g), ("B", &mut b)] { + for (label, name, v) in [("R", tl!("Red"), &mut r), ("G", tl!("Green"), &mut g), ("B", tl!("Blue"), &mut b)] { ui.label(RichText::new(label).font(theme::mono(11.0)).color(t.text_faint)); - rgb |= widgets::value_field(ui, v, 0.0..=255.0, "", 38.0).changed(); + rgb |= widgets::value_field(ui, v, 0.0..=255.0, "", 38.0).accessible_name(name).changed(); } if rgb { *color = [r / 255.0, g / 255.0, b / 255.0, 1.0]; @@ -4335,8 +4454,9 @@ fn hex_edit(ui: &mut egui::Ui, id: egui::Id, color: &mut [f32; 4]) -> bool { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field).layout(egui::Layout::left_to_right(egui::Align::Center))); // Room for a pasted "#rrggbb"; the parse trims it, and the field shows plain digits otherwise. // `Frame::NONE`: the themed surface above is this field's frame. - let resp = - child.add(egui::TextEdit::singleline(&mut text).id(id).char_limit(7).desired_width(field.width()).frame(egui::Frame::NONE).font(theme::mono(12.0))); + let resp = child + .add(egui::TextEdit::singleline(&mut text).id(id).char_limit(7).desired_width(field.width()).frame(egui::Frame::NONE).font(theme::mono(12.0))) + .accessible_name(tl!("Color")); if resp.has_focus() { ui.data_mut(|d| d.insert_temp(id, text.clone())); } else { @@ -4372,7 +4492,7 @@ fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::B let at = opts.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(opts.len(), |i| i + 1); opts.insert(at, (None, "Clear")); let mut mode = if *clear || held { None } else { Some(b.mode) }; - if !widgets::dropdown_wheel_hovered(ui, id, &mut mode, &opts, 96.0).0.is_empty() { + if !widgets::dropdown_wheel_hovered(ui, id, tl!("Mode"), &mut mode, &opts, 96.0).0.is_empty() { *clear = mode.is_none(); if let Some(m) = mode { b.mode = m; @@ -4383,7 +4503,7 @@ fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::B /// Options-bar Smoothing % (the brush's stroke smoothing; the live stroke and the commit use it). fn smoothing_field(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings, width: f32) { let mut sm = (b.smoothing.amount * 100.0).round(); - if widgets::value_field(ui, &mut sm, 0.0..=100.0, "%", width).changed() { + if widgets::value_field_named(ui, tl!("Smoothing"), &mut sm, 0.0..=100.0, "%", width).changed() { b.smoothing.enabled = true; b.smoothing.amount = (sm / 100.0).clamp(0.0, 1.0); } @@ -4408,7 +4528,7 @@ fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings fn mixer_load_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::hover()); - let resp = ui.interact(r, ui.id().with("mixer-brush-load"), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with("mixer-brush-load"), Sense::click()); if resp.hovered() { ui.painter().rect_filled(r, t.radius_sm, t.hover); } @@ -4425,7 +4545,7 @@ fn mixer_load_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } p.rect_stroke(chip, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Current Brush Load"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Current Brush Load"))); let resp = resp.on_hover_text(tl!("Current Brush Load")); let mut run = None; egui::Popup::menu(&resp).show(|ui| { @@ -4459,7 +4579,7 @@ fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushSettings, st brush_tip(ui.painter(), c, 7.0, b.hardness, tip); ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", b.size.round() as i64), egui::FontId::proportional(9.5), t.text_dim); icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Brush Preset picker"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Brush Preset picker"))); if resp.on_hover_text(tl!("Brush Preset picker")).clicked() { if state.brush_picker.is_some() { crate::brush_picker::close(state); @@ -4501,7 +4621,7 @@ fn layer_drag_and_drop( actions: &mut Vec<(String, Value)>, ) { let t = Tokens::get(ctx); - let key = egui::Id::new("layer-drag"); + let key = egui::Id::unique("layer-drag"); // A drag from the mask thumbnail (`starts_mask_drag`) moves the mask, not the layer. let mask_drag = ctx.data(|d| d.get_temp::(mask_drag_key())).is_some_and(|(id, _)| id == l.id.0); if resp.drag_started() && !mask_drag { @@ -4603,7 +4723,7 @@ fn layer_drop_zone( position: &'static str, actions: &mut Vec<(String, Value)>, ) { - let Some(dragged) = ctx.data(|d| d.get_temp::(egui::Id::new("layer-drag"))) else { return }; + let Some(dragged) = ctx.data(|d| d.get_temp::(egui::Id::unique("layer-drag"))) else { return }; let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default(); if target.0 == LayerId(dragged) && selected.len() > 1 && selected.contains(&target.0) { return; @@ -4637,7 +4757,7 @@ type FxDrag = (u64, Option); type MaskDrag = (u64, MaskKind); fn mask_drag_key() -> egui::Id { - egui::Id::new("mask-drag") + egui::Id::unique("mask-drag") } /// Which mask's thumbnail (where it was drawn last frame) the row's drag starts on, if any. @@ -4656,7 +4776,7 @@ fn starts_mask_drag(ctx: &egui::Context, l: &Layer, resp: &egui::Response) -> Op } fn fx_drag_key() -> egui::Id { - egui::Id::new("fx-drag") + egui::Id::unique("fx-drag") } /// Start dragging the effects of `layer`: one (`effect`) or all of them. @@ -4741,7 +4861,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi } // The whole eye column of the row takes the click, so it never opens the Layer Style dialog. let eye_cell = Rect::from_min_max(pos2(rect.left() + 30.0, rect.top()), pos2((rect.left() + 58.0).min(rect.right()), rect.bottom())); - if ui.interact(eye_cell, ui.id().with(("fx-eye", l.id.0, i)), Sense::click()).clicked() { + if ui.interact(eye_cell, ui.scope_id().with(("fx-eye", l.id.0, i)), Sense::click()).clicked() { let mut params = json!({"layer": l.id.0, "visible": !on}); if let (Some(index), Some(o)) = (i.checked_sub(1), params.as_object_mut()) { o.insert("index".into(), json!(index)); @@ -4762,7 +4882,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }, ); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name.clone())); if resp.double_clicked() { crate::layer_style::open_for_layer(app, l.id, kind); } @@ -4864,14 +4984,14 @@ mod color_tests { let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { let at = (ui.cursor().min, ui.available_width()); - ui.data_mut(|d| d.insert_temp(egui::Id::new("test-origin"), at)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("test-origin"), at)); color_field(app, ui); }, app, ); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); h.run_steps(2); - let (o, w) = h.ctx.data(|d| d.get_temp::<(Pos2, f32)>(egui::Id::new("test-origin"))).unwrap(); + let (o, w) = h.ctx.data(|d| d.get_temp::<(Pos2, f32)>(egui::Id::unique("test-origin"))).unwrap(); let field_w = w - 38.0 - 14.0 - 16.0; let press = |h: &mut egui_kittest::Harness<'static, PhotocraftApp>, at: Pos2, pressed: bool| { h.event(egui::Event::PointerMoved(at)); @@ -4906,14 +5026,14 @@ mod color_tests { let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { let at = (ui.cursor().min, ui.available_width()); - ui.data_mut(|d| d.insert_temp(egui::Id::new("test-origin"), at)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("test-origin"), at)); color_field(app, ui); }, app, ); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); h.run_steps(2); - let (o, w) = h.ctx.data(|d| d.get_temp::<(Pos2, f32)>(egui::Id::new("test-origin"))).unwrap(); + let (o, w) = h.ctx.data(|d| d.get_temp::<(Pos2, f32)>(egui::Id::unique("test-origin"))).unwrap(); let field_w = w - 38.0 - 14.0 - 16.0; let field = o + vec2(38.0 + 8.0 + field_w * 0.5, 60.0); let strip = o + vec2(38.0 + 8.0 + field_w + 8.0 + 7.0, 40.0); @@ -5414,7 +5534,7 @@ mod color_tests { assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0, 0, 0], "both corners are black"); // What the knob is drawn from on the very next frame: the field kept the saturation the // drag ended at (not the zero black reads back as), together with the hue. - let remembered: (f32, f32) = h.ctx.data(|d| d.get_temp(egui::Id::new(("color-field-hue", false)))).expect("the field remembers what it showed"); + let remembered: (f32, f32) = h.ctx.data(|d| d.get_temp(egui::Id::unique(("color-field-hue", false)))).expect("the field remembers what it showed"); assert!(remembered.1 > 0.9, "the saturation the corner was dragged to: {remembered:?}"); let shown = field_hsva(egui::ecolor::Hsva::new(0.0, 0.0, 0.0, 1.0), remembered); assert!(shown.s > 0.9, "the knob stays at the right edge, not the left: {remembered:?}"); @@ -5978,8 +6098,8 @@ mod tool_flyout_drag_tests { }); buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); let origin = buttons[slot_index].center(); - let key = egui::Id::new(("tool-slot", slot_index)); - let flyout_id = egui::Id::new("tool-flyout"); + let key = egui::Id::unique(("tool-slot", slot_index)); + let flyout_id = egui::Id::unique("tool-flyout"); let initial = app.ui.tool; frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(origin), primary(origin, true)]); assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_none()); @@ -6034,18 +6154,21 @@ mod type_flyout_tests { let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]); frame(&mut app, &ctx, 1.36, vec![]); - assert_eq!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).map(|(id, _)| id), Some(egui::Id::new(("tool-slot", index)))); + assert_eq!( + ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::unique("tool-flyout"))).map(|(id, _)| id), + Some(egui::Id::unique(("tool-slot", index))) + ); frame(&mut app, &ctx, 1.4, vec![pointer(at, false)]); frame(&mut app, &ctx, 1.45, vec![]); // The held button's tool is selected, as Photoshop does (#4453). assert_eq!(app.ui.tool, Tool::Type); - let key = egui::Id::new(("tool-slot", index)); + let key = egui::Id::unique(("tool-slot", index)); let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).unwrap(); let row = egui::pos2(menu.left() + 65.0, menu.top() + 39.0 + 8.0); frame(&mut app, &ctx, 2.0, vec![egui::Event::PointerMoved(row), pointer(row, true)]); frame(&mut app, &ctx, 2.05, vec![pointer(row, false)]); assert_eq!(app.ui.tool, Tool::VerticalType); - assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_none()); + assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::unique("tool-flyout"))).is_none()); } /// #4444: click and hold on Change Screen Mode opens its list like the tool flyouts, without @@ -6163,7 +6286,7 @@ mod type_flyout_tests { let texts = frame_texts(&mut app, &ctx, 3.0 + 0.1 * f64::from(k), vec![]); assert!(!texts.contains(&tip(Tool::Eyedropper)), "held {k}: {texts:?}"); } - assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_some(), "the flyout is open"); + assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::unique("tool-flyout"))).is_some(), "the flyout is open"); // Released, with the flyout still open: none for the held tool or another one. frame(&mut app, &ctx, 4.1, vec![pointer(eyedropper, false)]); for (start, at, tool) in [(4.5, eyedropper, Tool::Eyedropper), (6.0, brush, Tool::Brush)] { @@ -6198,7 +6321,7 @@ mod type_flyout_tests { }); buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); let at = buttons[index].center(); - let flyout = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_some(); + let flyout = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::unique("tool-flyout"))).is_some(); let secondary = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; let primary = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; // A right-click on the button of an open flyout closes it (#4932), so open only a closed one. @@ -6212,7 +6335,7 @@ mod type_flyout_tests { }; // A press on the flyout itself keeps it open (the row picks on release). open(&mut app, 1.0); - let menu = ctx.memory(|m| m.area_rect(egui::Id::new(("tool-slot", index)).with("flyout"))).unwrap(); + let menu = ctx.memory(|m| m.area_rect(egui::Id::unique(("tool-slot", index)).with("flyout"))).unwrap(); frame(&mut app, &ctx, 1.5, vec![egui::Event::PointerMoved(menu.center()), primary(menu.center(), true)]); assert!(flyout(&ctx), "a press on the flyout keeps it"); frame(&mut app, &ctx, 1.55, vec![primary(menu.center(), false)]); @@ -6281,10 +6404,13 @@ mod type_flyout_tests { let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]); frame(&mut app, &ctx, 1.36, vec![]); - assert_eq!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).map(|(id, _)| id), Some(egui::Id::new(("tool-slot", index)))); + assert_eq!( + ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::unique("tool-flyout"))).map(|(id, _)| id), + Some(egui::Id::unique(("tool-slot", index))) + ); frame(&mut app, &ctx, 1.4, vec![pointer(at, false)]); frame(&mut app, &ctx, 1.45, vec![]); - let key = egui::Id::new(("tool-slot", index)); + let key = egui::Id::unique(("tool-slot", index)); let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).unwrap(); let row = egui::pos2(menu.left() + 65.0, menu.top() + 39.0 + 8.0); frame(&mut app, &ctx, 2.0, vec![egui::Event::PointerMoved(row), pointer(row, true)]); @@ -6320,7 +6446,7 @@ mod type_flyout_tests { frame(&mut app, &ctx, 1.36, vec![]); frame(&mut app, &ctx, 1.4, vec![pointer(at, false)]); frame(&mut app, &ctx, 1.45, vec![]); - let key = egui::Id::new(("tool-slot", index)); + let key = egui::Id::unique(("tool-slot", index)); let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).unwrap(); // The last of the four 26-point rows. let row = egui::pos2(menu.left() + 65.0, menu.bottom() - 20.0); @@ -6465,14 +6591,14 @@ mod toolbar_tests { if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { toolbar(app, ui); let left = ui.available_rect_before_wrap().left(); - ui.data_mut(|d| d.insert_temp(egui::Id::new("toolbar-test-left"), left)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("toolbar-test-left"), left)); } }, app, ); PhotocraftApp::setup_context(&h.ctx, theme); h.run_steps(2); - let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("toolbar-test-left"))).unwrap_or(0.0); + let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::unique("toolbar-test-left"))).unwrap_or(0.0); let single = left(&h); assert!(!h.state().ui.panels.toolbar_double, "{theme:?}"); @@ -6685,7 +6811,7 @@ mod embedding_tests { } fn drawn(h: &egui_kittest::Harness<'_, PhotocraftApp>, panel: &str) -> bool { - egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new(panel)).is_some() + egui::containers::panel::PanelState::load(&h.ctx, panel_state_id(&h.ctx, panel)).is_some() } /// An app that embeds the UI can hide the icon rail and the menu bar; both are on by default. @@ -6923,6 +7049,7 @@ mod compact_visual_regressions { let mut harness = toolbar_harness(kind, scale); harness.event(egui::Event::PointerMoved(pos2(25.0, 100.0))); harness.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -1500.0), modifiers: egui::Modifiers::NONE, @@ -7015,7 +7142,7 @@ mod compact_visual_regressions { harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); let mut regions = serde_json::Map::new(); for id in ["options_bar", "toolbar"] { - let r = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect; + let r = egui::containers::panel::PanelState::load(&harness.ctx, panel_state_id(&harness.ctx, id)).unwrap().outer_rect; regions.insert(id.into(), json!([r.left(), r.top(), r.width(), r.height()])); } let metadata = json!({"screen": [width, height], "scale": scale, "regions": regions}); @@ -7024,6 +7151,7 @@ mod compact_visual_regressions { harness.input_mut().events.extend([ egui::Event::PointerMoved(pos2(width * 0.5, 20.0)), egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Point, delta: vec2(-1500.0, 0.0), modifiers: egui::Modifiers::NONE, @@ -7223,6 +7351,7 @@ mod compact_visual_regressions { events: vec![ egui::Event::PointerMoved(pos2(800.0, 20.0)), egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Point, delta: vec2(-100.0, 0.0), modifiers: egui::Modifiers::NONE, @@ -7367,6 +7496,7 @@ mod compact_visual_regressions { // to step through blend modes (#4512). egui::Event::PointerMoved(if Tokens::get(&ctx).pro { pos2(width * 0.5, 20.0) } else { pos2(width * 0.5, 2.0) }), egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Point, delta: vec2(-1500.0, 0.0), modifiers: egui::Modifiers::NONE, @@ -7617,3 +7747,84 @@ mod sampler_readout_tests { assert_eq!(sampler_readouts(&mut app).len(), 2, "so does a new sampler"); } } + +#[cfg(test)] +mod panel_state_migration_tests { + use super::{migrate_panel_state, panel_state_id}; + use egui::containers::panel::PanelState; + use egui::{Context, Id, Rect, pos2, vec2}; + + fn state(width: f32) -> PanelState { + PanelState { outer_rect: Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 600.0)) } + } + + /// 0.36 saved `dock` under `Id::new("dock")`; 0.37 reads a parent-scoped key. + #[test] + fn the_parent_scoped_key_matches_the_panel_egui_shows() { + let ctx = Context::default(); + let mut output = ctx.run_ui(Default::default(), |ui| { + egui::Panel::right("dock").exact_size(410.0).show(ui, |ui| ui.set_min_width(ui.available_width())); + }); + output.textures_delta.clear(); + let key = panel_state_id(&ctx, "dock"); + assert_eq!(key, ctx.viewport_id().root_ui_id().with("dock")); + let panel = PanelState::load(&ctx, key).expect("the panel persisted under its parent-scoped id"); + assert_eq!(panel.size().x, 410.0); + assert_ne!(key, Id::unique("dock"), "the old persisted key is a different id"); + } + + #[test] + fn old_panel_widths_move_to_the_new_key_once() { + let ctx = Context::default(); + let old = Id::unique("dock"); + ctx.data_mut(|d| d.insert_persisted(old, state(410.0))); + migrate_panel_state(&ctx); + let new = panel_state_id(&ctx, "dock"); + assert_eq!(PanelState::load(&ctx, new).map(|p| p.size().x), Some(410.0)); + assert_eq!(PanelState::load(&ctx, old).map(|p| p.size().x), Some(410.0), "0.36 rollback still reads it"); + + // An existing 0.37 value wins, in a partial migration or on a later run. + ctx.data_mut(|d| d.insert_persisted(new, state(290.0))); + ctx.data_mut(|d| d.insert_temp(Id::unique("photocraft-panel-state-migrated"), false)); + migrate_panel_state(&ctx); + assert_eq!(PanelState::load(&ctx, new).map(|p| p.size().x), Some(290.0)); + assert_eq!(PanelState::load(&ctx, old).map(|p| p.size().x), Some(410.0)); + } + + /// 0.37 resize → 0.36 rollback resize → upgrade must keep the newer 0.36 width, not the + /// stale 0.37 one (the old key stays the only one a 0.36 build writes). + #[test] + fn a_0_36_rollback_resize_after_0_37_is_picked_up() { + use super::remember_panel_state; + let ctx = Context::default(); + let old = Id::unique("dock"); + let new = panel_state_id(&ctx, "dock"); + ctx.data_mut(|d| d.insert_persisted(old, state(410.0))); + migrate_panel_state(&ctx); + // This 0.37 session resized the dock; the pass-end stamp records it. + ctx.data_mut(|d| d.insert_persisted(new, state(290.0))); + remember_panel_state(&ctx); + // A 0.36 build then resized the same dock (it writes only the old key). + ctx.data_mut(|d| d.insert_persisted(old, state(330.0))); + ctx.data_mut(|d| d.insert_temp(Id::unique("photocraft-panel-state-migrated"), false)); + migrate_panel_state(&ctx); + assert_eq!(PanelState::load(&ctx, new).map(|p| p.size().x), Some(330.0), "the newer 0.36 width wins"); + assert_eq!(PanelState::load(&ctx, old).map(|p| p.size().x), Some(330.0), "and stays for a rollback"); + } + + /// A 0.37 resize after the migration is not overwritten by the unchanged old key. + #[test] + fn a_0_37_resize_after_the_migration_is_kept() { + use super::remember_panel_state; + let ctx = Context::default(); + let old = Id::unique("dock"); + let new = panel_state_id(&ctx, "dock"); + ctx.data_mut(|d| d.insert_persisted(old, state(410.0))); + migrate_panel_state(&ctx); + ctx.data_mut(|d| d.insert_persisted(new, state(290.0))); + remember_panel_state(&ctx); + ctx.data_mut(|d| d.insert_temp(Id::unique("photocraft-panel-state-migrated"), false)); + migrate_panel_state(&ctx); + assert_eq!(PanelState::load(&ctx, new).map(|p| p.size().x), Some(290.0)); + } +} diff --git a/crates/ui-egui/src/paths_scroll_tests.rs b/crates/ui-egui/src/paths_scroll_tests.rs index 924e6e6290..f29f1943ed 100644 --- a/crates/ui-egui/src/paths_scroll_tests.rs +++ b/crates/ui-egui/src/paths_scroll_tests.rs @@ -43,6 +43,7 @@ fn scrolling_the_paths_list_does_not_move_the_footer() { // Down to the end of the list and past it, then back up. let dir = if i < 30 { -1.0 } else { 1.0 }; h.event(egui::Event::MouseWheel { + source: egui::MouseWheelSource::Unknown, unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, dir), phase: egui::TouchPhase::Move, diff --git a/crates/ui-egui/src/pattern_fill_ui.rs b/crates/ui-egui/src/pattern_fill_ui.rs index 574a221367..33c7b3da3a 100644 --- a/crates/ui-egui/src/pattern_fill_ui.rs +++ b/crates/ui-egui/src/pattern_fill_ui.rs @@ -179,7 +179,7 @@ pub(crate) fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map