Compare commits

...
Author SHA1 Message Date
Harrison O'Connor-Hoover 8a4ed48354 fix(ui): numeric fields drop oversized text, paste and IME events before layout (part of #2150) (#2258)
A single oversized input event reached egui text layout in a numeric field even though it could never become a valid value. number_edit now drops Text, Paste and IME events longer than 128 scalar values while the field has focus, and the custom parser rejects accumulated text over the cap before normalizing. DragValue keeps focus, accessibility, drag and arithmetic behaviour. Six harness tests cover the event kinds, the Unicode boundary, accumulation, undo, and document text fields staying unrestricted.

Part of #2150.
2026-10-16 17:25:50 -07:00
wwew dd69f0797d fix(engine): reserve the define-brush tip and selection-mask buffers fallibly (#212) (#5469)
brush.defineFromSelection allocated the sampled tip and the selection mask infallibly over the whole selection, so a large selection could abort the process. Both now go through the engine's fallible allocator and return a normal error that leaves the brush, presets, history and document untouched. A failpoint test asserts all four.

Part of #212.
2026-10-16 17:23:08 -07:00
safvanedge aecac608b4 fix(ui): Type tool caret stays solid after typing, navigation or a click, then resumes blinking (#5511) (#5512)
The caret's blink phase was tied to the wall clock, so an arrow key or keystroke that landed in the off half-cycle moved an invisible caret. A transient caret_blink_time on UiState is reset on every handled key event, on a click that places the caret, and while dragging; draw_overlay computes the phase from it so the caret is solid for 530 ms after each interaction, and the repaint is scheduled to the next phase boundary.

Fixes #5511.
2026-10-16 17:20:53 -07:00
Vikram Bhaskaran c7d2c85faf fix(ui): Color Picker's Only Web Colors snaps every edit and is remembered across pickers (#4995) (#5442)
With Only Web Colors on, every picker edit (field, sliders, hex, Lab/CMYK, screen sampler, Current) snaps through the clamped 216-colour ramp. The checkbox is remembered on the Color panel state so the foreground, field, command, Layer Style and type-style pickers open with the same setting. Opening with it on snaps the colour and its original together, so OK without an edit leaves a fill layer unchanged. The hex field follows the colour when another path changes it while focused.

Fixes #4995.
2026-10-16 17:19:03 -07:00
Nigel Ihza Firdausy 9628290b36 fix(ui): Layer › New Fill Layer › Solid Color opens the Color Picker, creating the layer on OK (#5144) (#5166)
Creating a Solid Color fill from the Layer menu or the Layers panel footer now opens the shared Color Picker seeded with the foreground colour. The layer is created on OK in one history step; Cancel leaves the document and history untouched. Parameterised calls (CLI, MCP, actions) still create the layer directly.

Fixes #5144.
2026-10-16 17:13:51 -07:00
Marco Arnulfo db46713c2c Unsaved-changes prompt: the default answer takes keyboard focus, so arrows and Enter work (part of #6441) (#6466) 2026-10-16 17:03:15 -06:00
Jason Xiang 06ad07ab9b fix(io): color-manage grayscale WebP exports (#5875) 2026-10-16 17:02:54 -06:00
Jason Xiang f61ed26e14 fix(io): honor version-aware OpenRaster group isolation (#5872) 2026-10-16 17:02:23 -06:00
Jason Xiang 840edc311c fix(io): preserve linear grayscale profiles for selected EXR parts (#5867) 2026-10-16 17:01:52 -06:00
Jason Xiang 462adeadd2 test(io): cover mixed-region CMYK TIFF composites (#5588) 2026-10-16 17:01:10 -06:00
xsvm 8fe2c74f58 perf(testkit): sample RSS from /proc/self/statm, not smaps, on Linux (#5399)
memory-stats reads /proc/self/smaps by default on Linux: every sample formats and parses one
block per mapping while holding the mmap lock. The benches' RssSampler polls it every 2 ms, so
on a profile of bevel_bench --size 4000x3000 the rss-sampler thread was ~22% of all samples
(smaps_account 14.7%, smaps_pte_entry 6.3%), contending with the measured threads.

Enable the crate's always_use_statm feature: one short read of the kernel's RSS counters, the
same number VmRSS reports. The measured code is unchanged; only the harness overhead goes.
2026-10-16 17:00:43 -06:00
wwew 93f6376c5e docs: write product name as PhotoCraft in package descriptions and comments (#5615) 2026-10-16 16:59:41 -06:00
wwew e33384c9ba docs: write product name as PhotoCraft in crate module docs (#5455) 2026-10-16 16:58:50 -06:00
PerchenkoAnton f360cb0b4a fix(engine): give artboard exports unique indexed filenames (#6655) 2026-10-16 16:57:33 -06:00
wwew 651647fdf6 docs(cli): write product name as PhotoCraft in USAGE and description (#5449) 2026-10-16 16:56:48 -06:00
Jason Xiang f1c45b887e fix(codecs): respect PAM ENDHDR line boundaries (#5874) 2026-10-16 16:38:54 -06:00
Marco Arnulfo 6a7fc88c79 Crop tool: custom ratios keep three decimals, 1.414 : 1 no longer becomes 1.4 : 1 (fixes #6487) (#6503) 2026-10-16 16:38:35 -06:00
Marco Arnulfo 97f50b4714 Pen: the path in progress stays visible while panning with Space (fixes #6592) (#6625) 2026-10-16 16:38:11 -06:00
Diego Chagas ad6463b50f Paragraph text: a word wider than the box breaks at the box edge (#6694) (#6696)
parley's default overflow wrap only breaks at UAX #14 opportunities, so
a word longer than the paragraph box ran out past its edge and resizing
the box changed nothing. Paragraph text now sets OverflowWrap::BreakWord,
so such a word wraps at the box edge as Photoshop does; a space or hyphen
still wins over a mid-word break, a box that fits the word lays out as
before, and point text is untouched.
2026-10-16 16:37:45 -06:00
Korg1ch 3c75db7a80 fix(linux): avoid duplicate AppImage desktop launcher when managed by AM / AppMan (#6392) (#6394)
* fix(linux): avoid duplicate AppImage desktop launcher when managed by AM / AppMan (#6392)

* test(linux): cover AM / AppMan launcher detection in AppRun smoke test (#6392)
2026-10-16 16:37:17 -06:00
midasmatterhorn 19227022ca Histogram: scale bar heights linearly to the tallest bin, not sorted.get(250) (#6638) 2026-10-16 16:36:58 -06:00
Nicolas Falesy 54a4782849 perf(camera-raw): Shadows/Highlights 1.9× faster, bit for bit the same (#5964) (#6247)
The local Laplacian filter behind Shadows and Highlights builds a whole
pyramid per reference value (up to 96). Three costs dominated it:

- blur_step called mirror() (an integer remainder) for every tap of every
  pixel; the source rows are now found once per row and columns mirror only
  within two pixels of an edge.
- up() blurred a zero-stuffed plane, three samples in four zero; it now adds
  only the nonzero taps, in the same order.
- the small pyramid levels were split into one-row rayon tasks, costing more
  in scheduling than the rows; planes under 32K samples run on one thread.

The output is bit for bit the same: a new test checks blur_step and up
against the plain versions on odd, even, tiny and parallel-sized planes.
2026-10-16 16:26:19 -06:00
JGF 2a952f164c Windows/Linux: open files in the running PhotoCraft instead of a second instance (#3708) (#3755)
Fixes #3708; part of #1540 (Flatpak needs its own follow-up: each sandbox has its own loopback).

A second launch hands its files to the running instance over an authenticated loopback channel (random port, 32-byte token in a private instance.json, constant-time check, bounded clients, requests and paths). Files are acknowledged only once the UI has taken them; the instance shuts the hand-off down before a CPU-compatibility relaunch; it runs only in the per-user settings mode. --new-instance and PHOTOCRAFT_NEW_INSTANCE=1 opt out.

Thanks to @MrCloudy2 (#2358), whose Linux version first handled the relaunch and per-user endpoints, and @Desayuno64 (#5273), whose shutdown flag and read deadline informed the final design.
2026-10-15 22:48:30 -07:00
Tanish Manku d12be8ecd0 test(ui-egui): canvas_16f and rotation preview tests run on the app's wgpu device (#6454)
Both harnesses built their device with egui_kittest's .wgpu() (egui's default limits) instead of
gpu_canvas::wgpu_setup() (the adapter's own limits, what the app requests), as #5954 fixed for the
background_redraw and navigator tests. On that device:
- canvas_16f's sixteen_bit_gradient_on_screen and thirty_two_bit_preview_exposes_values_above_one
  panicked in egui-wgpu ("Failed to create staging buffer for index data", renderer.rs:984), every
  run, single-threaded too;
- arbitrary_rotation_preview's two tests failed "the fixture must use the GPU canvas".
Both binaries pass on the app's device.
2026-10-15 22:33:14 -07:00
58f855a40f Save As on Linux: the file type picked sets the extension (#6121, #4328, #4394) (#6151)
* Save As on Linux: the file type picked sets the extension (#6121, #4328, #4394)

The GTK file chooser behind the desktop portal keeps the typed name when
another file type is picked, and rfd 0.17.2 reads only the file name from
the portal's answer, so Untitled-1.psd with PNG picked was written as a
PSD. On Linux, Save As now asks the portal's FileChooser.SaveFile itself
(over the zbus already used for the portal), reads current_filter, and
gives the name that type's extension: an extension PhotoCraft saves is
replaced, none or another is appended. If that names an existing file, the
dialog opens again under the new name so the portal asks before replacing
it. rfd remains the fallback without a portal, and this can go once an rfd
release reads current_filter (PolyMeilex/rfd#341).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Save As on Linux: keep typed extensions, open on the stem, follow the request handle

Review follow-ups, checked on real GNOME 46 and KDE Plasma 6.7.5 sessions
(Wayland and X11):

- An extension the user typed is kept, as in PolyMeilex/rfd#341: only a
  name with none, or still with the extension the dialog opened with, takes
  the picked type's. GTK reports the type even when it is untouched, so
  art.png with the PSD type left alone was saved as art.psd.
- The dialog opens on the stem (test), as on Windows (#4407): with
  test.psd in the name and PNG picked, GTK asked to replace test.psd,
  which the save leaves alone. A name that then exists is asked about
  again under that name; the rule compares against the suggested name, so
  a test.psd GTK completes back still becomes test.png.
- Response 2 (GNOME's dialog closed with Esc) is a cancel: it fell back to
  rfd, which opened a second Save As.
- The answer is awaited on the handle SaveFile returns, which a portal
  older than 0.9 picks itself (the Request documentation).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* style(automation): format smart filter refresh closure

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: skylar taylor-barrick <[email protected]>
2026-10-15 22:47:17 -06:00
Tanish Manku 6368e6a851 GPU memory follow-ups (#5973 review): fit cleared on device loss and on the CPU canvas; measured when switching to a document whose canvas is current; notice names the 3/8 card cap; VRAM override documented; perf rows count memory like the app (#6458) 2026-10-15 21:37:05 -07:00
Tanish Manku 6d3443c098 GPU memory: discrete cards aren't charged for the document; GPU Memory preference; GPU Memory Full status (#5973)
* GPU memory: a graphics card's own memory isn't charged for the document; GPU Memory preference; GPU Memory Full in the status bar

Many layers hit a cliff: past what the GPU memory budget leaves for layer pages, every edit
that redraws the whole document (opacity, visibility, a nudge) goes to the CPU compositor.
Measured on 3000x2000 full-canvas layers (RTX 5090, 31 GB RAM, Memory Usage 8 GB): 150
layers took 2.6 s per such edit, 300 layers 6 s. The budget was Memory Usage minus the
document's pixels and History, which is right where the GPU shares system memory, but a
discrete card's memory doesn't hold the document, so 150 layers of 6 MP (3.6 GB) squeezed
the budget below what they needed.

- Automatic budget: discrete adapters are no longer charged for the document's pixels
  (integrated and software adapters still are). 150 layers: 2.6 s -> 25 ms.
- Preferences › Performance › GPU Memory (MB, 0 = Automatic; performance.gpuMemoryMb) sets
  the budget outright. 300 layers with 12 GB: 6 s -> 45-72 ms.
- The status bar shows "GPU Memory Full" while the active document's layers don't fit
  (both status bar styles); hovering gives the numbers, clicking opens Preferences ›
  Performance. Its Graphics rows show the budget and what the layers need. The slow CPU
  redraw notice gives the numbers and points at GPU Memory.
- Headless: the preference is a normal pref (prefs.set), and ui.state's perf.timings
  reports gpuLayerFit {doc, needed, budget} next to gpuBudgetMb.

Next (owner-approved plan): after #5370, read the card's real limit from the driver
(wgpu memory budget thresholds) and shrink on out-of-memory; then redraw the visible
area on the GPU first when layers still don't fit.

* GPU memory: typed discrete check; the status and budget follow a GPU Memory change at once (measure_fit); perf rows P60/P61 for whole-document edits among 150 full-canvas layers under the app's budget

* GPU memory on top of #5234: memory_budget takes the card's memory (vram) and the GPU Memory value; the document's pixels are charged unless the card's own memory is known; a set GPU Memory is capped at the card's share; benches use video_memory
2026-10-15 21:22:46 -07:00
FinnNkandClaude Opus 5.5 297520b9c8 perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory (#5234)
* perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory

The compositor and canvas budgets came from system RAM only, so a 16 GB card in a 96 GB machine planned for up to 8 GB of compositor pages plus 24 GB of display textures. Detect a discrete adapter's dedicated memory (DXGI on Windows through winsafe, amdgpu sysfs on Linux) and cap each budget at three eighths of it. PHOTOCRAFT_VRAM_MB simulates a smaller card. Fixes #4536.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(gpu): keep a 16-bit canvas on the GPU in 8-bit when floats don't fit

With the canvas budget capped by a small card's memory, a 16-bit document under the float-canvas pixel limit could need more than the budget as Rgba16Float and fall back to the CPU canvas although its Rgba8Unorm set fits. Choose the float canvas only when it fits the budget, which is now shared atomically so format_for can read it while the renderer is locked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(gpu): detect a card's memory once per adapter, and test the Linux reader

video_memory built a DXGI factory each time it was asked, three times at
startup; it now remembers the answer per (vendor, device), so a GPU retry
that picks another adapter still gets its own. The amdgpu sysfs reader
takes the drm folder as a parameter and is tested against a temporary
folder: the matching card's VRAM, a card without the file, garbage ids, a
malformed size, no match and no folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 21:05:51 -07:00
Tanish Manku 57437ea1a9 fix(ui-egui): canvas textures fit the device's texture limit; GPU tests hold the GPU lock on the app's device (#5954)
- The CPU canvas texture and the crop's beyond-canvas bands were reduced to fit MAX_TEXTURE (4096)
  whatever the device allows. On a device with a smaller limit (2048 on some older GPUs and WebGL
  contexts) a 2600 px document was uploaded at full size: egui asserts and the backend can't
  create it. They now fit the smaller of the two. Test: cpu_canvas_texture_fits_the_device_limit.
- background_redraw and navigator GPU tests built their harness with egui's default wgpu limits
  instead of the app's wgpu_setup (the adapter's own limits), and the GPU canvas lost the device
  on it ("Device is lost", every run on an RTX 5090). They now use wgpu_setup.
- Eleven tests built a wgpu device without photocraft_testkit::gpu_lock, which testkit requires so
  devices aren't created concurrently; they now hold it.
2026-10-15 21:05:39 -07:00
skylar taylor-barrick 7053f703a5 Merge pull request #2933 from 1Git2Clone/ci-wallclock
CI: cut wall time, starting with the Windows test job
2026-10-15 21:38:18 -06:00
skylar taylor-barrick fd1faf4e13 Merge pull request #6381 from dylanmtaylor/ci/shared-freebsd-workflow
ci: delegate FreeBSD workflow to craft-common-actions and add nix check
2026-10-15 21:34:22 -06:00
skylartaylor 8f58ea4629 ci: integrate current main while retaining strict test gates 2026-10-16 05:25:24 +02:00
Dylan M. Taylor 0cc161e2a8 ci: delegate FreeBSD workflow to craft-common-actions and add nix check 2026-10-15 22:04:09 -04:00
PerchenkoAnton 3cb3580a46 fix(psd): recover layered imports with damaged merged data (#6170) 2026-10-15 19:14:05 -06:00
skylar taylor-barrick b630d7cb9a Security: enforce the automation read boundary on stored smart-filter params (#5684)
* Security: enforce the automation read boundary on stored smart-filter params

Smart filters store a command id and params inside the document, so a
crafted .pcraft could resolve stored ambient paths (displace mapPath,
color-lookup LUT files) at render time without the authorize gate:
synchronous smart-object rendering has no Session argument, spawned job
threads drop the requesting session's authority, and a trusted UI
render could warm the process-wide stack cache with a stage the gate
never saw.

Thread the requesting session's gate through a thread-local in jobs.rs
(entered around command execution, reinstalled on spawned workers and on
job apply, restored on drop), re-check stored filter commands in
render_in before source resolution and any cache lookup and in
apply_filter_to_surface, and re-authorize layer.smartFilter.setParam
with the complete merged params after merging stored document params —
including edits whose request contains no path and actions.play steps.
Denial is conservative: the document is unchanged and no worker starts.

Regression tests in the automation crate cover rerender, cached-stage
reuse, merged-param edits, action playback, background jobs, and filter
transfer, for both the displace map and color-lookup LUT vectors.

* Review: carry render authority through Session::edit and surface denials

Session::edit is the engine's universal mutation entry (linked-file
refresh, transforms, place); renders reached through it ran with no
authorization scope installed, so stored smart-filter params read
ambiently there even after the execute-path fix. Install the scope in
Session::edit as well, keyed on the session's authorize gate.

apply_filter_to_surface also conflated a denied filter with an unknown
one: authorize_render_command's Result was discarded with .ok()?, so a
denial silently rendered unfiltered pixels and the stage entered the
process-wide stack cache. The signature becomes
Result<Option<Surface>>: Err for denials (callers propagate), None for
ids that aren't pixel filters (pass-through semantics unchanged), and an
unreadable map keeps its deliberate pass-through. apply_smart_filter and
apply_smart_filters propagate instead of cur.clone().

Two tests: a refresh driven outside command execution (Session::edit)
is denied and leaves the document unchanged; a denied render caches no
stage. Nine authorization tests pass, engine suite and strict Clippy
clean, panic hunt green.

* ui_set description: name the layerFilter field main added

The Layers-panel filter dropdown (#5048) added layerFilter to the
control method's UI_SET_FIELDS without extending the ui_set tool's
description, so the drift guard (mcp.rs, ui_set_description_names_every_
accepted_field) fails on Windows CI for any branch built on main. Name
the field with its patch shape: by (kind, name or mode), kinds (a list
of layer kinds), name (matches part of the layer name), mode (a blend
mode name). The 40 MCP tests pass; strict Clippy clean.

* wasm size: compose and algo join cms at opt-level "z" to clear the 24 MiB gate

Main's browser build is 171 KB over the 24 MiB wasm gate in CI
(25,337,343 bytes), so every branch built on current main fails the
web size check. Demote three pixel crates in the wasm-release profile:

- cms: transforms are built once and cached (transform::cached),
  so "z" costs little runtime (measured -42 KB).
- compose: the browser app composites per frame on the GPU (crates/gpu,
  WebGPU/WebGL2), so the CPU compositor is not the browser's hot path
  there (measured -122 KB).
- algo: filters run per pixel on every interactive filter application.
  "z" there is the lever of last resort (measured -331 KB), taken
  because the gate is enforced in CI; if browser filters feel too slow,
  restore 3 here and reclaim the bytes from a feature-gated decoder.

Final size 24,811,949 bytes locally, 354 KB under the gate; the native
profiles are untouched. If a maintainer prefers keeping algo at 3, the
alternative is raising the self-imposed 24 MiB early-warning limit
(Cloudflare's hard cap is 25 MiB) - a policy call in packaging/web/
package.sh, not a code fix.
2026-10-15 18:19:26 -06:00
Tanish Manku 2b3d05636f Follow-ups to #5801/#5859: precheck docs name the group nesting limit; precheck test covers valid ids and null; Free Transform refusals translated; stranded-backup error tested (#6365) 2026-10-15 15:32:08 -07:00
Tanish Manku fdaa9b8316 Free Transform: New Layer applies the transform first; tests for switching tool, layer or New Layer (#5665, #5667) (#5859)
* Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)

Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.

* New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order

* Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal

* Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied

* Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute

* Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines)
2026-10-15 15:26:13 -07:00
Tanish Manku 8376363d8c Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files (#5801)
* Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files

A retro against "everything is drivable headless; the UI is a layer" found the shortcut
work leaning on the UI. Fixed:

- The shell's command table (UI_COMMANDS: View › Zoom In, the panels…) moves into the
  engine as data (shell_commands::SHELL_COMMANDS; menus::UI_COMMANDS is now an alias), with
  the tools (shell_commands::TOOLS: id, Photoshop name, default key; a test keeps it equal
  to the toolbar). edit.keyboardShortcuts binds, lists, imports, exports and checks them for
  conflicts with no UI: a headless .kys import takes tool keys, and an export is the whole set.
  Tools may share a key (Shift cycling); a command given a tool's key takes it from them.
- Reserved system keys are refused by the engine (the dialog uses the same check) and left
  out of imported sets.
- importKys / exportKys / summarize take {"path": …} to read or write files, like
  actions.save; the automation gate refuses those paths for MCP.

* Keyboard shortcuts: bindings commit only after their exports are written (a bad target changes nothing); docs: the command knows the tools

* Keyboard shortcuts: both export targets are resolved and checked before either file is written; a failed later write removes the earlier one

* docs: reserved keys per platform

* Keyboard shortcuts export: files are staged next to their targets and moved into place only when all are written; a failure leaves the user's files untouched

* Keyboard shortcut exports all or nothing: unique create_new staging and backups beside each target, restored on any failure; the same target twice refused; docs: Ctrl+Shift+Esc

* Shortcut exports: a failed backup move removes its placeholder; undo restores backups without deleting first and names any it couldn't; read-only targets refused up front
2026-10-15 15:26:09 -07:00
Tanish Manku b09aeae2e2 Low-Latency Drawing preference (vsync off, opt-in), plus present-mode overrides for testers (#1343) (#5274)
* PHOTOCRAFT_PRESENT_MODE / PHOTOCRAFT_FRAME_LATENCY launch overrides for latency A/B tests (#1343)

Linux traces put the remaining stroke latency in presentation queueing (the app's frame fits
in one refresh; the rest waits on the swap chain), and an NVIDIA user cured it by forcing
vsync off. Testers can now compare without rebuilding: PHOTOCRAFT_PRESENT_MODE (fifo,
mailbox, immediate, autovsync, autonovsync) and PHOTOCRAFT_FRAME_LATENCY (1-3), applied over
the platform default. Bad values are logged and ignored. The active mode is logged at start,
kept in GpuInfo.present and shown in every stroke trace line.

RTX 5090, Vulkan, 120 Hz: default autovsync/latency 1 waits 6 ms per 8.3 ms frame; mailbox
renders every 2.3 ms with 0.2 ms wait, so each vsync shows the newest frame.

* docs: present mode and frame latency overrides

* Present mode overrides never request a mode the driver may lack (Mailbox crashed on X11/NVIDIA)

* Preferences › Performance › Low-Latency Drawing, on by default: no vsync wait while drawing (#1343)

Testers' traces put the ~50 ms stroke lag in the swap chain's vsync wait. With vsync off it
disappears (X11/NVIDIA; Windows/NVIDIA via the driver), and a shorter queue doesn't help. The
surface now presents with AutoNoVsync by default: wgpu picks Immediate, else Mailbox, else
Fifo by what the driver offers, so no unsupported mode is ever requested. Turning the
preference off brings vsync back (smooth, but late). It applies at the next launch, like the
other graphics settings. Where only Immediate exists the canvas may tear while it changes;
frames render as fast as input arrives during a stroke and stop when idle.

* Runtime and launch controls: ui.set strokeTrace, --stroke-trace, --present-mode, --frame-latency

The retro's remaining gap: the stroke trace and the presentation overrides were env-only. The trace now switches at runtime over the control channel (ui.set {"strokeTrace": true|false|"stderr"|path}) and with --stroke-trace; --present-mode / --frame-latency sit beside their env vars and win over them. Bad values are reported and ignored; none fails a launch.

* docs: strokeTrace beside the other ui.set fields

* docs: regenerate the scorecard after rebasing onto main

* Low-Latency Drawing: frames capped at 240 fps without vsync (after #4472); ui.set strokeTrace takes on/off only (a CSV path is launch-only); tearing documented as usual (Immediate); pref hidden on web; doc comment order

* stroke_trace: from_value doc says ui.set passes only on or off

* Low-Latency Drawing ships off (opt-in), per the owner's decision

* docs: present mode default is vsync (Low-Latency Drawing ships off)
2026-10-15 15:23:47 -07:00
IvanandClaude Opus 5.5 691a022b12 CI: skip the test (windows-latest) gate in cancelled runs
With always() the gate also ran when a newer push cancelled the run,
taking a runner slot only to report a red check on the superseded
commit. !cancelled() still runs it when a test leg fails (a skipped
required check would count as passing), but not in a cancelled run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:29:16 +03:00
IvanandClaude Opus 5.5 f0f0d6f58a CI: the gpu test group covers the other ui-egui wgpu tests
The canvas|gpu|mip|type_tool name filter missed ui-egui unit tests that
build a Harness with a real wgpu device: background_redraw's GPU tests,
panel_docking's floating_group_titles_are_opaque_in_studio_themes and
swap_arrow's rendered_glyph_has_a_left_head_and_a_lower_right_head. Name
them in both overrides, so they share the 4-thread cap and get the CI
retries. The other .wgpu() harnesses only render when a fixture env var
is set, or are #[ignore]d.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:22:15 +03:00
IvanandClaude Opus 5.5 3bd9bc72a7 Tests: monitor_macos answers nextest's --list
monitor_macos is harness = false, so nothing answered cargo-nextest's
discovery: `--list --format terse` ran the whole test and nextest failed
to parse its output on macOS. List the one test, and nothing for
`--ignored`, as mac_menu_appkit already does; `monitor_macos --exact`
still runs the body.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:22:14 +03:00
IvanandClaude Opus 5.5 b23a01aa8f Tests: postscript_only_style waits for the font scan
selected_style reads the font engine through try_shared, which returns None while the system
font scan is still running. Under cargo test the test passed only because an earlier test in the
same process had already finished the scan; run on its own (nextest runs every test that way, and
`cargo test -- --exact` does too) it saw no engine and got "Regular" instead of "SemiBold". It now
waits for the scan with photocraft_text::shared(), as every_theme_shows_the_type_options does.
On main, alone, it failed before this and passes after, under both cargo test and nextest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:03:09 +03:00
IvanandClaude Opus 5.5 4a93095939 CI: keep a test (windows-latest) check that reports the test result
Splitting the Windows tests renamed their check from `test (windows-latest)` to one per leg. No
status check is required on main today, but if one is added under the old name it would wait
forever. A small job under that name now fails unless the platform pick and the whole test matrix
passed (`needs` gives one result for all legs, so a failing Linux leg fails it too). It checks the
result instead of exiting 0 under `if: always()`, which would pass with every leg red. Suggested
by Zach in review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:29:13 +03:00
IvanandClaude Opus 5.5 e99f09b306 Tests: mac_menu_appkit answers nextest's --list
The test runs without a harness (AppKit needs the main thread), so nothing answered the `--list`
that cargo-nextest sends each test binary: on macOS the binary ran the whole test as its listing
and nextest failed on the output. It now lists itself in libtest's terse format, and nothing for
`--ignored`; a run passes `--exact <name>`, which falls through to the test as before. Found by
Zach running `cargo nextest run --workspace` on a Mac.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:28:41 +03:00
IvanandClaude Opus 5.5 e5bf9c22c9 CI: run the Windows tests on two runners instead of three
The integration tests run as one leg again. On the Windows runner (EPYC 7763, 3 runs) that leg
took 14.3-15.3 min, against 10.5-10.8 for each half, so the slowest leg is now the integration
one at ~15 min instead of the unit one at ~12.8. In exchange each PR that runs Windows takes two
Windows runners instead of three and ~7 fewer runner-minutes: every leg pays its own setup
(~1 min) and builds the workspace libraries its tests link against (~4 min even from sccache).
That matters more while PRs queue for the account's concurrent-job limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:48:31 +03:00
IvanandClaude Opus 5.5 39904a27ce Docs: CI runs nextest, the craft-fonts tests have their own job
Comments and docs still said CI runs cargo test, that the craft-fonts tests run a second time in
the Linux job, and that CI's debuginfo matches the dev profile everywhere. development.md also
notes that test builds compile ui-egui unoptimized.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 11:32:35 +03:00
IvanandClaude Opus 5.5 110d58bea9 CI: split the Windows integration tests over two runners; retry flaky GPU tests
nextest --partition splits the integration tests in two, so Windows runs three legs: on the
Windows runner (EPYC 7763, 2 runs) the integration halves took 9.8 and 10.8 min against 13.8 for
one, leaving the unit leg (~12.8) as the longest.

A ci nextest profile retries the GPU tests: on WARP, under load, one hit wgpu's poll timeout once
in ~40 runs while measuring. A test that passes on retry is reported as FLAKY.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 10:06:39 +03:00
IvanandClaude Opus 5.5 60d682d65d CI: cache the Windows workspace libraries with sccache
rust-cache keeps only the dependencies, so every run recompiled the workspace crates. sccache
caches their libraries (test executables can't be cached and still link). Warm, on the Windows
runner (EPYC 7763, 2-3 runs), the unit leg built in 7.1 min instead of 11.5 and the integration
leg in 4.3 instead of 8.8. Only main writes (PRs use SCCACHE_GHA_RW_MODE=READ_ONLY); a full set
of entries was ~90 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 08:58:53 +03:00
IvanandClaude Opus 5.5 6382148eb8 CI: run the Windows tests as two parallel legs
Unit tests (--lib --bins, plus doctests) and integration tests (--test '*', plus Clippy and the
layering check) now run on two Windows runners at once. The feature selection is the same, so
both legs restore one dependency cache; the integration leg saves it, so Clippy's metadata stays
in it. On the Windows runner (EPYC 7763) the job took 28.3 min; the legs take 18.2 and 18.3,
running the same 6095 tests between them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 07:48:54 +03:00
IvanandClaude Opus 5.5 2ff756b51a Tests: build ui-egui without optimization in the test profile
Its library and unit tests were the biggest test-build units after engine's (12.6 of ~63 compile
minutes on the Windows CI runner), and its tests run no heavy pixel code of their own. On the
Windows runner (EPYC 7763, 3 runs vs 7): build 18.9 -> 15.4 min, test run 10.0 -> 10.5. Only test
builds change; cargo run and cargo build use the dev profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 06:36:32 +03:00
IvanandClaude Opus 5.5 fa17b6f4b1 CI: run the Linux without-heif and craft-fonts builds in parallel
They ran after the main tests in the Linux test job and added ~10 min to its critical path (and
were why that job ran out of disk). Now a two-leg job restores the test job's cache read-only
(shared-key: test) and saves nothing, so the cache budget (#2688) doesn't grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:08:46 +03:00
IvanandClaude Opus 5.5 7b1dd35b7b CI: report every failing test, not just the first
With the binaries running in parallel, running the rest after a failure costs little, and a red
main no longer hides other failures behind the first one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:07:17 +03:00
IvanandClaude Opus 5.5 bf93459200 CI: no debuginfo for the Windows test build
Writing PDBs for ~200 test binaries took 2.2 of ~21 build minutes on the Windows runner (3 runs
each on the same CPU model). A panic still reports its file and line; Linux keeps
line-tables-only for backtraces with lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:06:06 +03:00
IvanandClaude Opus 5.5 b9aa935d19 CI: run tests with nextest and skip building examples (#2204)
cargo test runs the ~200 test binaries one after another; on Windows that took 14 of the job's
37 minutes, 5 of them in gpu's parity tests on the software adapter. nextest runs them in parallel
with the GPU group from .config/nextest.toml. The examples were built and linked but never run;
Clippy's --all-targets still checks them. Doctests get their own step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 01:41:45 +03:00
148 changed files with 6161 additions and 588 deletions

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@
gpu = { max-threads = 4 }
[[profile.default.overrides]]
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
test-group = 'gpu'
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
+132 -29
View File
@@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
inputs:
fuzz:
@@ -22,8 +23,8 @@ env:
PHOTOCRAFT_NO_PRINT: "1"
LPDEST: photocraft-no-such-printer
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
# debug info: faster codegen and links, and a smaller target dir (the Linux test job ran out of
# disk with full debug info). The Windows test legs drop debuginfo entirely (see the test job).
CARGO_PROFILE_DEV_DEBUG: line-tables-only
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
@@ -43,7 +44,7 @@ jobs:
name: select platforms
runs-on: ubuntu-latest
outputs:
os: ${{ steps.pick.outputs.os }}
legs: ${{ steps.pick.outputs.legs }}
web: ${{ steps.pick.outputs.web }}
steps:
- uses: actions/checkout@v4
@@ -57,9 +58,15 @@ jobs:
run: |
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
# release.yml builds and tests macOS (a failing release is fixed and rebuilt).
all='["ubuntu-latest","windows-latest"]'
# Windows tests run as two legs in parallel: the unit tests and the integration tests. As
# one job they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting
# the integration tests over a third runner (nextest --partition) saved ~2 min of wall
# time but cost ~7 runner-minutes and one more Windows slot per PR.
linux='{"os":"ubuntu-latest","part":"all"}'
win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; }
all="[$linux,$(win unit),$(win integration)]"
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
echo "os=$all" >> "$GITHUB_OUTPUT"
echo "legs=$all" >> "$GITHUB_OUTPUT"
echo "web=true" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -72,20 +79,25 @@ jobs:
echo "web=false" >> "$GITHUB_OUTPUT"
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
echo "os=$all" >> "$GITHUB_OUTPUT"
echo "legs=$all" >> "$GITHUB_OUTPUT"
else
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
fi
test:
timeout-minutes: 100
name: test (${{ matrix.os }})
name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
needs: platforms
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: ${{ fromJSON(needs.platforms.outputs.os) }}
include: ${{ fromJSON(needs.platforms.outputs.legs) }}
env:
# No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes.
# 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: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }}
steps:
- uses: actions/checkout@v4
@@ -108,21 +120,44 @@ jobs:
# Only main saves caches; PRs restore main's. Every PR used to save its own set (up to
# 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's.
# A failing test still saves: the cache holds compiled dependencies, not test results.
# The Windows legs share one key (same dependencies); the integration leg saves it,
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
cache-on-failure: true
# The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
# after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
# sccache caches the workspace crates' compiled libraries, which rust-cache leaves out:
# warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read
# main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688).
# Test executables can't be cached, so a PR still links its tests.
- name: Install sccache (Windows)
if: runner.os == 'Windows'
uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.18.0
- name: Use sccache (Windows)
if: runner.os == 'Windows'
env:
MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi
shell: bash
- name: Format
if: runner.os == 'Linux'
run: cargo fmt --all -- --check
# Keep formatting, strict Clippy, layering and scorecard checks ahead of tests.
- name: Clippy
if: matrix.part == 'all' || matrix.part == 'integration'
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
- name: Dependency layering
if: matrix.part == 'all' || matrix.part == 'integration'
run: cargo xtask layers
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
@@ -131,36 +166,104 @@ jobs:
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
# nextest runs the test binaries in parallel (cargo test runs them one at a time), with the
# GPU tests capped by the `gpu` group in .config/nextest.toml (#2204).
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: [email protected]
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
# Separate native commands into steps so Windows reports either command's failure.
# `--lib --bins --tests` leaves out the examples, which cargo test would build and link but
# never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so
# they get their own step.
#
# On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every
# integration-test target); the feature selection stays the same, so both legs share the
# dependency cache. The `ci` profile fails fast and terminates hung tests
# (.config/nextest.toml); assertion failures are not retried.
- name: Test
run: cargo nextest run --workspace --locked --features heif --profile ci
env:
PART: ${{ matrix.part }}
run: |
case "$PART" in
unit) targets=(--lib --bins) ;;
integration) targets=(--test '*') ;;
*) targets=(--lib --bins --tests) ;;
esac
cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}"
shell: bash
- name: Doctests
run: cargo test --doc --workspace --locked --features heif
if: matrix.part == 'all' || matrix.part == 'unit'
run: cargo test --workspace --locked --features heif --doc
# The Windows tests used to be one job named `test (windows-latest)`; they're now legs named
# `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old
# name reporting, so a required status check on it (none today) would keep working. It fails
# unless the whole test matrix and both Linux variants passed. `needs` reports one result
# for all matrix legs; when a PR runs no Windows legs it still checks every Linux result.
windows-gate:
timeout-minutes: 5
name: test (windows-latest)
needs: [platforms, test, test-variants]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- env:
PLATFORMS: ${{ needs.platforms.result }}
TEST: ${{ needs.test.result }}
VARIANTS: ${{ needs.test-variants.result }}
run: |
echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS"
[ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = success ]
# The two other builds of the Linux tests, in parallel with the test job instead of after it (they
# added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is
# that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is
# tight (#2688).
test-variants:
timeout-minutes: 100
name: test (${{ matrix.variant }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
variant: [without heif, with craft-fonts]
steps:
- uses: actions/checkout@v4
- name: Install Linux system dependencies (egui/winit/rfd)
run: |
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: test
save-if: false
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: [email protected]
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
# error for distributors who leave it out.
- name: Test without heif
if: runner.os == 'Linux'
if: matrix.variant == 'without heif'
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
# --features heif` of the Test step: any other package or feature selection resolves a
# different feature set, recompiles the whole dependency graph into the same target dir and
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
# not what is built); engine and the apps only pass text on to them (#6120).
# The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese
# font tests that skip without it run. Keep the test job's exact `--workspace --features heif
# --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds.
# Filter execution to the three crates that read the optional font input (#6127).
- name: Check out craft-fonts
if: runner.os == 'Linux'
if: matrix.variant == 'with craft-fonts'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
@@ -168,12 +271,12 @@ jobs:
path: craft-fonts
- name: Test with craft-fonts
if: runner.os == 'Linux'
if: matrix.variant == 'with craft-fonts'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: >-
cargo nextest run --workspace --locked --features heif --profile ci
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
corpus:
+7 -35
View File
@@ -27,38 +27,10 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test + package)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build, test and package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
set -e
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked --features heif
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
# device at once, so the GPU integration tests run one at a time here.
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1
# The release package, as release.yml builds it. Free the debug build's disk first.
rm -rf target/debug
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
# 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
cargo-build-extra-args: "--features heif"
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
run-package-check: true
+33
View File
@@ -0,0 +1,33 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
+21 -5
View File
@@ -75,9 +75,18 @@ opt-level = 2
opt-level = 1
# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link
# time and PDB size for the ~260 test binaries (#2204). CI sets the same through
# CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a
# debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
debug = "line-tables-only"
# Test builds compile ui-egui without optimization: its library and unit tests were the biggest
# test-build units after engine's (12.6 of ~63 compile minutes on the Windows CI runner), and its
# tests run no heavier pixel code of their own (that lives in the crates that stay optimized).
# Measured on CI: Windows build 18.9 -> 15.4 min, test run 10.0 -> 10.5. `cargo run` and
# `cargo build` use the dev profile and are unaffected.
[profile.test.package.photocraft-ui-egui]
opt-level = 0
[profile.release]
lto = "thin"
codegen-units = 1
@@ -127,17 +136,24 @@ opt-level = "z"
[profile.wasm-release.package.naga]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level.
# Pixel processing keeps its speed-oriented optimization level, with two exceptions.
# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs
# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2),
# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm
# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on
# every interactive filter application; "z" there is the size lever of last resort (−331 KB),
# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here
# and reclaim the bytes from a feature-gated decoder instead.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
opt-level = 3
[profile.wasm-release.package.photocraft-cms]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-compose]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-algo]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-cli"
description = "Headless Photocraft: convert, inspect, run commands, batch, MCP server"
description = "Headless PhotoCraft: convert, inspect, run commands, batch, MCP server"
version.workspace = true
edition.workspace = true
license.workspace = true
+2 -2
View File
@@ -1,4 +1,4 @@
//! Headless Photocraft command line. `run` is the whole program, so it can be
//! Headless PhotoCraft command line. `run` is the whole program, so it can be
//! tested in-process as well as through the binary.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -12,7 +12,7 @@ use photocraft_io::ExportOptions;
use serde_json::{Value, json};
pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
photocraft-cli: headless PhotoCraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-web"
description = "Photocraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
description = "PhotoCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
version.workspace = true
edition.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,4 +1,4 @@
//! Photocraft in the browser.
//! PhotoCraft in the browser.
//!
//! Runs the same [`photocraft_ui_egui::PhotocraftApp`] as the desktop app through eframe's web
//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
+1 -1
View File
@@ -4,7 +4,7 @@ version.workspace = true
edition.workspace = true
license.workspace = true
default-run = "photocraft"
description = "Photocraft: an open-source, native image editor"
description = "PhotoCraft: an open-source, native image editor"
build = "build.rs"
[features]
+25
View File
@@ -443,6 +443,14 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
.unwrap_or_default()
}
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
/// turned on), read before the window opens: it picks the surface's present mode.
pub fn read_low_latency(path: Option<&Path>) -> bool {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing;
v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default)
}
/// Read the policy leniently at startup, including old settings without renderingMode.
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
@@ -463,6 +471,23 @@ mod tests {
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
}
#[test]
fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() {
let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("preferences.json");
let read = |json: &str| {
std::fs::write(&file, json).unwrap();
read_low_latency(Some(&file))
};
assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#));
assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#));
assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)");
assert!(!read("not json"));
assert!(!read_low_latency(None), "no settings file: the default (off)");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn display_server_preference_is_read_before_the_window_opens() {
use photocraft_engine::prefs::LinuxDisplayServer;
+131 -6
View File
@@ -1,8 +1,18 @@
//! Photocraft desktop app.
//! PhotoCraft desktop app.
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [files…]`
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [files…]`
//!
//! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new
//! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any
//! `--control` launch (see `single_instance`).
//!
//! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how
//! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`;
//! `presentation`); `--stroke-trace` times each brush stroke's frames (`PHOTOCRAFT_STROKE_TRACE`;
//! `photocraft_ui_egui::stroke_trace`).
//!
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
@@ -44,8 +54,15 @@ mod linux_libs;
mod logging;
mod monitor_profile;
mod photoshop_settings;
// Save As through the desktop portal, which reports the file type picked (#6121).
#[cfg(target_os = "linux")]
mod portal_save;
mod presentation;
mod screen_color;
mod services;
// macOS gets one instance from LaunchServices (open-documents Apple events).
#[cfg(any(not(target_os = "macos"), test))]
mod single_instance;
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
mod tablet;
@@ -191,6 +208,10 @@ fn main() -> eframe::Result {
// reported in a notice once the app is up; a control token is a usage error like a bad port.
let mut unreadable_paths: Vec<String> = Vec::new();
let mut safe_gpu = false;
let mut new_instance = false;
// Options that apply only to the process they're given to; a launch with any keeps its own.
let mut launch_options: Vec<&'static str> = Vec::new();
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args_os().skip(1);
while let Some(a) = args.next() {
@@ -208,10 +229,40 @@ fn main() -> eframe::Result {
None => control_token = None,
},
Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from),
Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from),
Some("--safe-gpu") => safe_gpu = true,
Some("--in-window-menus") => in_window_menus = true,
Some("--automation-read-root") => {
launch_options.push("--automation-read-root");
automation_read_root = args.next().map(std::path::PathBuf::from);
}
Some("--automation-write-root") => {
launch_options.push("--automation-write-root");
automation_write_root = args.next().map(std::path::PathBuf::from);
}
Some("--safe-gpu") => {
launch_options.push("--safe-gpu");
safe_gpu = true;
}
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
launch_options.push(match flag {
"--present-mode" => "--present-mode",
"--frame-latency" => "--frame-latency",
_ => "--stroke-trace",
});
let flag = flag.to_string();
match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(v)) => match flag.as_str() {
"--present-mode" => present_mode_arg = Some(v),
"--frame-latency" => frame_latency_arg = Some(v),
_ => stroke_trace_arg = Some(v),
},
// A bad value is reported and ignored; it never fails the launch.
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
}
}
Some("--in-window-menus") => {
launch_options.push("--in-window-menus");
in_window_menus = true;
}
Some("--new-instance") => new_instance = true,
Some("--version") => {
println!("photocraft {}", photocraft_engine::build_info::long_version());
return Ok(());
@@ -243,6 +294,48 @@ fn main() -> eframe::Result {
}
}
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
// shared between users), and not for automation, `--new-instance` or per-process options.
#[cfg(not(target_os = "macos"))]
let single_instance = {
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
let context = single_instance::LaunchContext {
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
new_instance_flag: new_instance,
new_instance_env: new_instance_env.as_deref(),
control: control_port.is_some(),
unreadable_paths: !unreadable_paths.is_empty(),
launch_options: &launch_options,
};
match (single_instance::skip_reason(&context), services::config_dir()) {
(Some(reason), _) => {
log::info!("running as a separate PhotoCraft: {reason}");
None
}
(None, None) => None,
(None, Some(dir)) => {
let paths: Vec<String> = files.iter().map(|f| single_instance::absolute(f)).collect();
match single_instance::claim(&dir, &paths) {
single_instance::Launch::Forwarded => {
log::info!("handed {} file(s) to the running PhotoCraft", paths.len());
return Ok(());
}
single_instance::Launch::Primary(primary) => Some(primary),
single_instance::Launch::Standalone(reason) => {
log::warn!("starting a separate PhotoCraft: {reason}");
None
}
}
}
}
};
// Lives until the event loop returns, then `shutdown` lets the next launch become the primary.
#[cfg(not(target_os = "macos"))]
let single_instance = &single_instance;
#[cfg(target_os = "macos")]
let _ = (new_instance, &launch_options);
// The display server to open the window on: Xwayland for a pen on Wayland, which gives this
// app no pen input (#639).
#[cfg(target_os = "linux")]
@@ -364,6 +457,21 @@ fn main() -> eframe::Result {
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync;
}
// Testers' present mode and frame latency overrides (#1343), after the preference.
let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref());
options.wgpu_options.surface = present.apply(options.wgpu_options.surface);
let present_desc = presentation::describe(&options.wgpu_options.surface, &present);
// Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out.
let uncapped = !matches!(
options.wgpu_options.surface.present_mode,
eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync
);
log::info!("presentation: {present_desc}");
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
@@ -392,6 +500,7 @@ fn main() -> eframe::Result {
}
}
let mut app = PhotocraftApp::new(Session::new(), services);
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = custom_titlebar;
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
@@ -423,6 +532,10 @@ fn main() -> eframe::Result {
// composites on the CPU).
let info = &mut app.perf.gpu_info;
info.preference = pref.name().to_string();
info.present = present_desc.clone();
if let Some(v) = &stroke_trace_arg {
app.stroke_trace.set(v);
}
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
@@ -485,6 +598,11 @@ fn main() -> eframe::Result {
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
// Files from later launches (Explorer, file managers) open here as new tabs.
#[cfg(not(target_os = "macos"))]
if let Some(primary) = single_instance {
app.services.os_events = Some(primary.connect(&cc.egui_ctx));
}
// Where file drags and drops are (winit 0.30 doesn't say).
app.services.cursor_pos = cursor::service(cc);
// The custom title bar's undecorated window must stay borderless, or its caption and
@@ -522,6 +640,13 @@ fn main() -> eframe::Result {
Ok(Box::new(app))
}),
);
// The window is gone: refuse further files and free the lock, so a launch that is waiting (or
// the CPU-compatibility relaunch below) becomes the primary instead of handing its files to a
// process that is exiting.
#[cfg(not(target_os = "macos"))]
if let Some(primary) = single_instance {
primary.shutdown();
}
// Closed or failed outside graphics initialization: not a driver crash. A renderer error
// keeps the marker, so the next start tries a safer backend, unless there is no safer one:
// a failed CPU start would otherwise pin every later start to CPU.
+183
View File
@@ -0,0 +1,183 @@
//! File › Save As through the XDG desktop portal on Linux (#6121, #4328, #4394).
//!
//! The GTK file chooser behind the portal keeps the typed name when the user picks another file
//! type, and rfd 0.17 reads only the file name from the portal's answer, so `image.psd` with PNG
//! picked came back as `image.psd` and was written as a PSD. The answer does say which type was
//! picked (`current_filter`), so Save As asks the portal itself and gives a name the dialog left
//! alone that type's extension ([`crate::services::with_chosen_type`]). rfd stays the fallback
//! (zenity, or no portal), and this can go once an rfd release reads the picked type
//! (PolyMeilex/rfd#341).
//!
//! The dialog is attached to the window on X11. On Wayland it has no parent: naming the window to
//! the portal needs an xdg-foreign handle, which takes `unsafe` code the workspace forbids.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
/// One file type in the dialog: its label and extensions (`("PNG", ["png"])`).
pub type Filter = (String, Vec<String>);
/// A Save dialog to show.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SaveRequest {
/// The portal's parent window identifier (`x11:<xid>`), or empty.
pub parent: String,
/// The name the dialog shows: the stem of the suggested file (`test`), see
/// `services::dialog_file_name`.
pub name: Option<String>,
/// The suggested file's name (`test.psd`): a name that comes back with its extension still
/// takes the extension of the type picked ([`crate::services::with_chosen_type`]).
pub suggested: Option<String>,
pub folder: Option<PathBuf>,
pub filters: Vec<Filter>,
/// The filter the dialog starts on.
pub current: Option<usize>,
}
/// What the user chose: the path, as typed, and the label of the file type picked.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Saved {
pub path: PathBuf,
pub filter: Option<String>,
}
/// The portal's parent window identifier for `window`: `x11:<xid>` on X11, empty elsewhere.
pub fn parent_id(window: Option<eframe::wgpu::rwh::RawWindowHandle>) -> String {
use eframe::wgpu::rwh::RawWindowHandle;
match window {
Some(RawWindowHandle::Xlib(h)) => format!("x11:{:x}", h.window),
Some(RawWindowHandle::Xcb(h)) => format!("x11:{:x}", h.window.get()),
_ => String::new(),
}
}
/// Shows the portal's Save dialog and waits for it. `Ok(None)` is Cancel; `Err` means the portal
/// couldn't show it (no portal, no FileChooser), so the caller falls back to rfd.
pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result<Option<Saved>, String> {
use futures_lite::StreamExt;
use zbus::blocking::Proxy;
let unique = conn.unique_name().ok_or("Portal connection has no unique name")?.as_str().trim_start_matches(':').replace('.', "_");
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let token = format!("photocraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
// Listen before asking, so a quick answer isn't missed.
let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
let chooser = Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser")
.map_err(|e| e.to_string())?;
let handle: OwnedObjectPath = chooser.call("SaveFile", &(req.parent.as_str(), "Save As", options(req, &token))).map_err(|e| e.to_string())?;
// A portal older than 0.9 ignores `handle_token` and answers on a request path of its own,
// which it returns: listen there instead, as the Request documentation asks.
let reply = if handle.as_str() == path {
futures_lite::future::block_on(replies.next())
} else {
let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", handle.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
futures_lite::future::block_on(replies.next())
}
.ok_or("The save dialog closed without an answer")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
answer(code, &results)
}
/// The `SaveFile` options for `req`.
fn options<'a>(req: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
let mut o: HashMap<&'static str, Value<'a>> = HashMap::from([("handle_token", Value::from(token)), ("modal", Value::from(true))]);
if let Some(name) = &req.name {
o.insert("current_name", Value::from(name.as_str()));
}
if let Some(folder) = &req.folder {
// A byte string with its terminating NUL, as the portal specifies.
use std::os::unix::ffi::OsStrExt;
let mut bytes = folder.as_os_str().as_bytes().to_vec();
bytes.push(0);
o.insert("current_folder", Value::from(bytes));
}
let wire = |(label, exts): &Filter| (label.clone(), exts.iter().map(|e| (0u32, format!("*.{e}"))).collect::<Vec<_>>());
if !req.filters.is_empty() {
o.insert("filters", Value::from(req.filters.iter().map(wire).collect::<Vec<_>>()));
}
if let Some(f) = req.current.and_then(|i| req.filters.get(i)) {
o.insert("current_filter", Value::from(wire(f)));
}
o
}
/// Reads the portal's `Response`: code 0 with the first `uris` entry. 1 is Cancel, and 2 a dialog
/// closed some other way, such as with Esc in GNOME's: no file either, and not a reason to show
/// rfd's dialog instead (it did, so Esc opened a second Save As).
fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Saved>, String> {
match code {
0 => {}
1 | 2 => return Ok(None),
_ => return Err(format!("the save dialog failed (portal response {code})")),
}
let uris: Vec<String> = results
.get("uris")
.ok_or("no file in the portal's answer")?
.try_clone()
.map_err(|e| e.to_string())?
.try_into()
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
let uri = uris.first().ok_or("no file in the portal's answer")?;
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
let filter = results.get("current_filter").and_then(|v| {
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
Ok(Some(Saved { path, filter }))
}
/// `file:///home/me/A%20b.psd` → `/home/me/A b.psd`; `None` for other schemes.
fn file_uri_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://")?;
// Only local files: `file:///…` or `file://localhost/…`.
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
if !rest.starts_with('/') {
return None;
}
let bytes = rest.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& let Some(b) = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())
{
out.push(b);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
use std::os::unix::ffi::OsStringExt;
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
}
/// Save As on Linux: the portal's dialog, with the name given the extension of the file type
/// picked. When that changes the name to one that already exists, the dialog opens again under
/// the new name, so the portal asks before replacing it (it asked only about the typed name).
pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Result<Option<PathBuf>, String> {
loop {
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label));
// With one type offered there is nothing to pick, and a typed extension is the user's.
let exts: &[String] = match picked {
Some(i) if req.filters.len() > 1 => &req.filters[i].1,
_ => &[],
};
let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
if path == saved.path || !path.exists() {
return Ok(Some(path));
}
req.name = path.file_name().map(|n| n.to_string_lossy().into_owned());
req.suggested = req.name.clone();
req.folder = path.parent().map(Path::to_path_buf);
req.current = picked;
}
}
#[cfg(test)]
mod tests;
+232
View File
@@ -0,0 +1,232 @@
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn file_uris_become_local_paths() {
assert_eq!(file_uri_path("file:///home/me/Untitled-1.psd"), Some(PathBuf::from("/home/me/Untitled-1.psd")));
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.png"), Some(PathBuf::from("/home/me/A bé.png")));
assert_eq!(file_uri_path("file://localhost/tmp/x.png"), Some(PathBuf::from("/tmp/x.png")));
assert_eq!(file_uri_path("sftp://host/x.png"), None);
assert_eq!(file_uri_path("file://host/x.png"), None);
}
#[test]
fn x11_windows_are_named_to_the_portal() {
use eframe::wgpu::rwh::{RawWindowHandle, XlibWindowHandle};
assert_eq!(parent_id(Some(RawWindowHandle::Xlib(XlibWindowHandle::new(0x3a0000f)))), "x11:3a0000f");
assert_eq!(parent_id(None), "");
}
/// What the mock portal answers to one `SaveFile` call: the response code, and the file and
/// filter label the user "picked".
type Script = Vec<(u32, Option<(&'static str, &'static str)>)>;
/// The options of each `SaveFile` call the mock portal received: current_name and the label of
/// current_filter.
type Seen = Arc<Mutex<Vec<(Option<String>, Option<String>)>>>;
/// A private session bus (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` that
/// answers `SaveFile` from `script`. `None` when there is no `dbus-daemon` to run.
fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
mock_portal_with(script, false)
}
/// [`mock_portal`]; with `own_paths` it is a portal older than 0.9, which ignores `handle_token`,
/// answers on a request path of its own and returns that from `SaveFile`, as a real dialog does
/// once the user is done with it (here after 300 ms).
fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
use std::io::BufRead;
let mut daemon =
std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?;
let mut address = String::new();
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
let seen: Seen = Arc::default();
let log = seen.clone();
std::thread::spawn(move || {
let mut script = script.into_iter();
for msg in zbus::blocking::MessageIterator::from(&server) {
let Ok(msg) = msg else { break };
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
continue;
}
let (_parent, _title, options): (String, String, HashMap<String, OwnedValue>) = msg.body().deserialize().unwrap();
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
let filter = options.get("current_filter").and_then(|v| {
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
log.lock().unwrap().push((text("current_name"), filter));
let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_");
let token = if own_paths { format!("old{}", log.lock().unwrap().len()) } else { text("handle_token").unwrap() };
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap();
if own_paths {
std::thread::sleep(std::time::Duration::from_millis(300));
}
let (code, pick) = script.next().unwrap_or((2, None));
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
if let Some((uri, label)) = pick {
results.insert("uris", Value::from(vec![uri.to_string()]));
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
}
server
.emit_signal(
header.sender().cloned().map(zbus::names::BusName::from),
path.as_str(),
"org.freedesktop.portal.Request",
"Response",
&(code, results),
)
.unwrap();
}
});
let client = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().build().unwrap();
Some((client, seen, daemon))
}
fn request(name: &str) -> SaveRequest {
let filters = [("PSD Document", &["psd", "psb"][..]), ("PNG", &["png"]), ("JPEG", &["jpg"])];
SaveRequest {
parent: String::new(),
name: Some(name.into()),
suggested: Some(name.into()),
folder: Some(PathBuf::from("/tmp")),
filters: filters.iter().map(|(l, e)| (l.to_string(), e.iter().map(|s| s.to_string()).collect())).collect(),
current: Some(0),
}
}
/// #6121 end to end through a real D-Bus: the name comes back as typed with the type picked,
/// and Save As writes it with that type's extension.
#[test]
fn save_as_follows_the_file_type_picked_in_the_portal_dialog() {
let dir = std::env::temp_dir().join(format!("photocraft-portal-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let typed = format!("file://{}/Untitled-1.psd", dir.display());
let typed: &'static str = Box::leak(typed.into_boxed_str());
let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (1, None), (0, Some((typed, "PSD Document")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
// PNG picked with `Untitled-1.psd` still in the name: written as Untitled-1.png.
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.png")));
assert_eq!(seen.lock().unwrap()[0], (Some("Untitled-1.psd".into()), Some("PSD Document".into())), "the request carried the name and type");
// Cancel.
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), None);
// The type the name already has: unchanged.
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.psd")));
daemon.kill().ok();
std::fs::remove_dir_all(&dir).ok();
}
/// The dialog opens on the bare name (`test`, see `services::dialog_file_name`), so GTK doesn't
/// ask to replace test.psd when PNG is picked: the type picked gives the extension, and a name
/// that then exists is asked about under that name.
#[test]
fn a_bare_name_takes_the_picked_type_and_an_existing_target_is_asked_about() {
let dir = std::env::temp_dir().join(format!("photocraft-portal-bare-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("test.psd"), b"open document").unwrap();
let uri = |name: &str| -> &'static str { Box::leak(format!("file://{}/{name}", dir.display()).into_boxed_str()) };
let Some((conn, seen, mut daemon)) = mock_portal(vec![
(0, Some((uri("test"), "PNG"))),
(0, Some((uri("test.psd"), "PNG"))),
(0, Some((uri("test"), "PSD Document"))),
(0, Some((uri("test.psd"), "PSD Document"))),
]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
// As File › Save As opens it: `test` shown, `test.psd` suggested.
let opened = || SaveRequest { suggested: Some("test.psd".into()), ..request("test") };
// PNG picked: test.png, and nothing was asked about test.psd.
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png")));
// The suggested extension back in the name (typed again, or GTK's completion) with PNG
// picked: test.png too, not a PSD written over test.psd.
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png")));
// The type untouched: test.psd, which exists, so the dialog asks again as test.psd (the
// portal's replace prompt is then about the file that is written).
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.psd")));
let seen = seen.lock().unwrap().clone();
let names: Vec<_> = seen.iter().map(|(name, _)| name.as_deref()).collect();
assert_eq!(names, [Some("test"), Some("test"), Some("test"), Some("test.psd")], "{seen:?}");
assert_eq!(seen[3].1.as_deref(), Some("PSD Document"));
daemon.kill().ok();
std::fs::remove_dir_all(&dir).ok();
}
/// GNOME's dialog closed with Esc answers 2 ("ended some other way"): no file, like Cancel, and
/// no fallback to rfd, which opened a second Save As.
#[test]
fn a_dialog_closed_some_other_way_is_a_cancel() {
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(2, None)]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("test")), Ok(None));
daemon.kill().ok();
}
/// An extension the user typed is theirs: `art.png` with the PSD type left as the dialog opened
/// saves a PNG, as it did before the type was read.
#[test]
fn a_typed_extension_wins_over_an_untouched_file_type() {
let typed: &'static str = "file:///tmp/photocraft-none/art.png";
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PSD Document")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/art.png")));
daemon.kill().ok();
}
/// A portal older than 0.9 answers on a request path of its own: the wait follows the handle
/// `SaveFile` returns, as the Request documentation asks, instead of the predicted path.
#[test]
fn the_answer_is_awaited_on_the_handle_the_portal_returns() {
let typed: &'static str = "file:///tmp/photocraft-none/Untitled-1.psd";
let Some((conn, _seen, mut daemon)) = mock_portal_with(vec![(0, Some((typed, "PNG")))], true) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Untitled-1.png")));
daemon.kill().ok();
}
/// When the extension the type gives names a file that exists, the dialog opens again under that
/// name (so the portal asks before replacing it) instead of overwriting it unasked.
#[test]
fn a_renamed_file_that_exists_is_confirmed_in_the_dialog() {
let dir = std::env::temp_dir().join(format!("photocraft-portal-exists-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("art.png"), b"old").unwrap();
let typed: &'static str = Box::leak(format!("file://{}/art.psd", dir.display()).into_boxed_str());
let confirmed: &'static str = Box::leak(format!("file://{}/art.png", dir.display()).into_boxed_str());
let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (0, Some((confirmed, "PNG")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("art.psd")).unwrap(), Some(dir.join("art.png")));
let seen = seen.lock().unwrap().clone();
assert_eq!(seen.len(), 2, "asked again: {seen:?}");
assert_eq!(seen[1], (Some("art.png".into()), Some("PNG".into())), "under the new name, on the picked type");
assert_eq!(std::fs::read(dir.join("art.png")).unwrap(), b"old", "nothing written by the dialog");
daemon.kill().ok();
std::fs::remove_dir_all(&dir).ok();
}
/// With only one type offered (the Swatches panel's .aco) a typed extension is kept.
#[test]
fn a_single_file_type_keeps_the_typed_extension() {
let typed: &'static str = "file:///tmp/photocraft-none/Swatches.ase";
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") };
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase")));
daemon.kill().ok();
}
+121
View File
@@ -0,0 +1,121 @@
//! Launch overrides for how frames reach the display, so testers can compare input-to-pixel
//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`,
//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo
//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may
//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a
//! warning in the log, never a failed start; a mode the driver may not offer is never requested.
//! The result is logged at start and shown in the stroke trace.
use eframe::egui_wgpu::SurfaceConfig;
use eframe::wgpu::PresentMode;
/// The present mode and frame latency this launch asked for (`None`: the default).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Overrides {
pub mode: Option<PresentMode>,
pub latency: Option<u32>,
}
/// Parse the two variables' values. Returns the overrides and a warning per rejected value.
pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec<String>) {
let mut warnings = Vec::new();
let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
let m = match v.to_ascii_lowercase().as_str() {
// Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or
// FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without
// vsync, wgpu picks Immediate, else Mailbox, else Fifo.
"fifo" | "vsync" => PresentMode::Fifo,
"autovsync" => PresentMode::AutoVsync,
"novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync,
_ => {
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
return None;
}
};
Some(m)
});
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::<u32>() {
Ok(n @ 1..=3) => Some(n),
_ => {
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
None
}
});
(Overrides { mode, latency }, warnings)
}
impl Overrides {
/// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` /
/// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged.
pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self {
let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok();
let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok();
let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref()));
for w in warnings {
log::warn!("{w}");
}
o
}
/// `surface` with the overrides applied.
pub fn apply(&self, surface: SurfaceConfig) -> SurfaceConfig {
SurfaceConfig {
present_mode: self.mode.unwrap_or(surface.present_mode),
desired_maximum_frame_latency: self.latency.or(surface.desired_maximum_frame_latency),
}
}
}
/// How `surface` presents, for the stroke trace and System Info: "fifo, latency 2", with
/// "(override)" after a value an environment variable set. wgpu's GL backend ignores the latency.
pub fn describe(surface: &SurfaceConfig, overrides: &Overrides) -> String {
let mode = match surface.present_mode {
PresentMode::AutoVsync => "autovsync",
PresentMode::AutoNoVsync => "autonovsync",
PresentMode::Fifo => "fifo",
PresentMode::FifoRelaxed => "fiforelaxed",
PresentMode::Immediate => "immediate",
PresentMode::Mailbox => "mailbox",
};
let latency = surface.desired_maximum_frame_latency.map_or("default".to_string(), |n| n.to_string());
let tag = |set: bool| if set { " (override)" } else { "" };
format!("{mode}{}, latency {latency}{}", tag(overrides.mode.is_some()), tag(overrides.latency.is_some()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_parse_and_bad_ones_are_ignored_with_a_warning() {
assert_eq!(parse(None, None), (Overrides::default(), vec![]));
assert_eq!(parse(Some(""), Some(" ")), (Overrides::default(), vec![]));
let (o, w) = parse(Some(" Mailbox "), Some("1"));
assert_eq!((o.mode, o.latency, w.len()), (Some(PresentMode::AutoNoVsync), Some(1), 0));
assert_eq!(parse(Some("vsync"), None).0.mode, Some(PresentMode::Fifo));
// Never a mode a driver may lack (wgpu crashes on it): no vsync falls back by support.
for v in ["IMMEDIATE", "mailbox", "novsync", "autonovsync"] {
assert_eq!(parse(Some(v), None).0.mode, Some(PresentMode::AutoNoVsync), "{v}");
}
for (mode, latency) in
[(Some("fast"), None), (None, Some("0")), (None, Some("4")), (None, Some("-1")), (None, Some("one")), (None, Some("99999999999"))]
{
let (o, w) = parse(mode, latency);
assert_eq!((o, w.len()), (Overrides::default(), 1), "{mode:?} {latency:?}");
}
}
#[test]
fn overrides_replace_only_what_they_set_and_are_described() {
let default = SurfaceConfig::HIGH_THROUGHPUT;
assert_eq!(Overrides::default().apply(default), default);
let latency_only = Overrides { mode: None, latency: Some(1) };
let s = latency_only.apply(default);
assert_eq!((s.present_mode, s.desired_maximum_frame_latency), (PresentMode::AutoVsync, Some(1)));
assert_eq!(describe(&s, &latency_only), "autovsync, latency 1 (override)");
let novsync = Overrides { mode: Some(PresentMode::AutoNoVsync), latency: None };
assert_eq!(describe(&novsync.apply(default), &novsync), "autonovsync (override), latency 2");
let none = SurfaceConfig { present_mode: PresentMode::Fifo, desired_maximum_frame_latency: None };
assert_eq!(describe(&none, &Overrides::default()), "fifo, latency default");
}
}
+117 -14
View File
@@ -95,18 +95,51 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
v
}
/// The name the save dialog starts with. On Windows a visible extension stays put when the user
/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed
/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog
/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A
/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
/// `path` with the extension of the file type picked in the save dialog (`chosen`), where the
/// dialog left the name alone (#6121): the format follows the extension, and the GTK file chooser
/// keeps the typed name when another type is picked. Only a name that still has the extension the
/// dialog opened with (`opened_with`, `Untitled-1.psd` with PNG picked) or none gets the type's
/// extension, as PolyMeilex/rfd#341 does. An extension the user typed that PhotoCraft saves
/// (`art.png` with the PSD type untouched, the way to save a PNG before this) is theirs and stays,
/// and so does one of the type's own (`.psb` for PSD). One PhotoCraft doesn't save (`scan.2024`)
/// is part of the name, and the type's extension is added after it.
#[cfg(any(target_os = "linux", test))]
pub(crate) fn with_chosen_type(path: &Path, opened_with: Option<&str>, chosen: &[String]) -> PathBuf {
let Some(first) = chosen.first() else { return path.to_path_buf() };
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
let opened_ext = opened_with.and_then(|n| Path::new(n).extension()).and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
match ext {
Some(ext) if chosen.contains(&ext) => path.to_path_buf(),
Some(ext) if opened_ext.as_ref() == Some(&ext) => path.with_extension(first),
Some(ext) if SAVE_FILTERS.iter().chain(OTHER_SAVE_FILTERS).any(|(_, exts)| exts.contains(&ext.as_str())) => path.to_path_buf(),
_ => {
let mut name = path.as_os_str().to_owned();
name.push(".");
name.push(first);
PathBuf::from(name)
}
}
}
/// The name the save dialog starts with. Where the type picked gives a bare name its extension
/// (`adds_extension`), the dialog gets the stem and the suggested type (listed first by
/// [`save_filters`]) supplies `.psd`:
/// - On Windows a visible extension stays put when the user picks another type (with File name
/// extensions shown, `image.psd` saved as PhotoCraft stayed `image.psd`, #4394).
/// - On Linux the portal's dialog keeps the name as typed, and before returning asks whether to
/// replace a file of that name, so `test.psd` with PNG picked asked to replace test.psd, which
/// the save leaves alone (#6121). From the stem [`with_chosen_type`] gives the picked type's
/// extension, and a name that then exists is asked about under that name
/// (`portal_save::save_as`).
///
/// A stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the
/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked.
fn dialog_file_name(suggested: &str, windows: bool) -> Option<String> {
/// macOS panel has no type list, and rfd's Linux dialogs don't say which type was picked.
fn dialog_file_name(suggested: &str, adds_extension: bool) -> Option<String> {
let path = Path::new(suggested);
let name = path.file_name()?.to_string_lossy().into_owned();
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) };
let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
let bare = adds_extension && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
}
@@ -148,17 +181,52 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
let filters = save_filters(&suggested);
let name = dialog_file_name(&suggested, cfg!(windows));
let dir = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf);
for (name, exts) in &filters {
dialog = dialog.add_filter(name, exts);
}
if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) {
if let Some(name) = &name {
dialog = dialog.set_file_name(name);
}
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
if let Some(dir) = &dir {
dialog = dialog.set_directory(dir);
}
let picked = dialog.save_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
// On Linux the portal's own dialog, which reports the file type picked (#6121); rfd if
// the portal can't show it.
#[cfg(target_os = "linux")]
{
use eframe::wgpu::rwh::HasWindowHandle;
let parent = crate::portal_save::parent_id(parent.and_then(|p| p.window_handle().ok()).map(|h| h.as_raw()));
let req = crate::portal_save::SaveRequest {
parent,
name: dialog_file_name(&suggested, true),
suggested: Path::new(&suggested).file_name().map(|n| n.to_string_lossy().into_owned()),
folder: dir,
filters,
current: Some(0),
};
Box::pin(async move {
let portal = zbus::blocking::connection::Builder::session()
.and_then(|b| b.build())
.map_err(|e| e.to_string())
.and_then(|conn| crate::portal_save::save_as(&conn, req));
match portal {
Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())),
Err(e) => {
log::info!("portal save dialog unavailable ({e}), using rfd");
dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
}
}
})
}
#[cfg(not(target_os = "linux"))]
{
let _ = filters;
let picked = dialog.save_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
}
}
};
// Without the thread the reply is dropped unanswered, which the app takes as Cancel.
@@ -585,6 +653,41 @@ mod tests {
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
use serde_json::{Value, json};
/// #6121: the file type picked in the save dialog gives the name its extension where the
/// dialog left the one it opened with, and only there.
#[test]
fn the_picked_file_type_gives_the_name_its_extension() {
let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let (png, psd) = (exts(&["png"]), exts(&["psd", "psb"]));
let opened = Some("Untitled-1.psd");
let cases = [
// PNG picked, the name as the dialog opened (or renamed, its extension kept).
("/pics/Untitled-1.psd", opened, &png, "/pics/Untitled-1.png"),
("/pics/art.psd", opened, &png, "/pics/art.png"),
("/pics/ART.PSD", opened, &png, "/pics/ART.png"),
// No extension: the type's. One PhotoCraft doesn't save is part of the name.
("/pics/art", opened, &png, "/pics/art.png"),
("/pics/scan.2024", opened, &png, "/pics/scan.2024.png"),
// Typed, an extension PhotoCraft saves is the user's: kept, whatever the type says
// (the PSD type untouched, or PNG picked and .jpg typed).
("/pics/art.png", opened, &psd, "/pics/art.png"),
("/pics/art.jpg", opened, &png, "/pics/art.jpg"),
("/pics/art.pcraft", opened, &png, "/pics/art.pcraft"),
// Already the type's: kept, including its other extensions.
("/pics/art.png", opened, &png, "/pics/art.png"),
("/pics/art.PNG", opened, &png, "/pics/art.PNG"),
("/pics/big.psb", opened, &psd, "/pics/big.psb"),
// The dialog opened on a .pcraft: that is the extension the type replaces.
("/pics/art.pcraft", Some("art.pcraft"), &png, "/pics/art.png"),
("/pics/art.psd", Some("art.pcraft"), &png, "/pics/art.psd"),
];
for (typed, opened, chosen, want) in cases {
assert_eq!(with_chosen_type(Path::new(typed), opened, chosen), PathBuf::from(want), "{typed} (opened {opened:?})");
}
// No type picked: the name as typed.
assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd"));
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
+648
View File
@@ -0,0 +1,648 @@
//! One PhotoCraft per user on Windows and Linux (#3708): a file opened from Explorer or a file
//! manager lands as a new tab in the running window, like Photoshop, instead of starting a second
//! copy of the program.
//!
//! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS
//! drops it when the process ends, crash included), listens on a loopback port and writes the port
//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its
//! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and
//! brings the window forward. Explorer starts one process per file when several are opened at once;
//! the lock decides the race, so they all end up as tabs in one window.
//!
//! The primary answers `ok` only once the UI has taken the paths. A launch that hears anything else
//! (the primary is still starting, hung, or quitting) keeps retrying the lock, so it becomes the
//! primary itself as soon as the old one lets go, and when its time runs out it starts a separate
//! instance: a file is never acknowledged and then dropped. [`Primary::shutdown`] runs when the
//! event loop ends (and before the CPU-compatibility relaunch), so a closing PhotoCraft refuses new
//! files and frees the lock.
//!
//! Only for the per-user settings folder ([`skip_reason`]): a portable copy's folder beside the
//! executable, or a `PHOTOCRAFT_CONFIG_DIR`, may be shared between users.
//! macOS doesn't need this: LaunchServices already sends a running app an open-documents event.
// Only the tests use it on macOS.
#![cfg_attr(target_os = "macos", allow(dead_code))]
use std::fs::{File, OpenOptions};
use std::io::{BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{SyncSender, sync_channel};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use photocraft_automation::security::{LineRead, generate_token, read_bounded_line, token_matches};
use photocraft_ui_egui::{OsEvent, OsEventsFn};
use serde_json::{Value, json};
const LOCK_FILE: &str = "instance.lock";
const INFO_FILE: &str = "instance.json";
/// More paths than this in one request is not a file manager.
const MAX_PATHS: usize = 4096;
/// Launches served at once; a stalled client holds only its own slot, never the next launch.
const MAX_CLIENTS: usize = 8;
/// Kept out of the hand-off's budget, so the primary answers before the launch gives up on it.
const REPLY_MARGIN: Duration = Duration::from_millis(500);
/// How long each side waits (shortened in tests).
#[derive(Clone, Copy, Debug)]
pub struct Timing {
/// A later launch's whole budget for handing its paths over (or taking the lock over).
pub handoff: Duration,
/// How long the primary waits for its UI to take a launch's paths before refusing them.
pub ack: Duration,
/// Total time a connection gets to send its request line.
pub request: Duration,
}
/// Generous enough for a primary that is still opening its window.
pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) };
/// Outcome of [`claim`].
pub enum Launch {
/// This process is the running instance; keep [`Primary`] alive and attach it to the app.
Primary(Primary),
/// The running instance took the paths; this process should exit.
Forwarded,
/// Couldn't do either (reason given): run as an independent instance.
Standalone(String),
}
/// What decides whether a launch takes part (see [`skip_reason`]).
#[derive(Clone, Copy)]
pub struct LaunchContext<'a> {
/// The settings folder is the platform's per-user one (not portable, not an override).
pub per_user_settings: bool,
/// `--new-instance`.
pub new_instance_flag: bool,
/// `PHOTOCRAFT_NEW_INSTANCE`, if set.
pub new_instance_env: Option<&'a str>,
/// `--control` or `PHOTOCRAFT_CONTROL_PORT`: automation gets its own process.
pub control: bool,
/// Paths that aren't valid Unicode: this launch reports them.
pub unreadable_paths: bool,
/// Options that only apply to the process they're given to (`--safe-gpu`, …).
pub launch_options: &'a [&'static str],
}
/// Why this launch runs as its own instance, or `None` to take part in single-instance.
pub fn skip_reason(c: &LaunchContext) -> Option<String> {
if !c.per_user_settings {
return Some("the settings folder isn't the per-user one (portable or PHOTOCRAFT_CONFIG_DIR)".into());
}
if c.new_instance_flag || c.new_instance_env.is_some_and(|v| !v.is_empty() && v != "0") {
return Some("--new-instance".into());
}
if c.control {
return Some("automation (--control) runs its own process".into());
}
if c.unreadable_paths {
return Some("some paths aren't valid Unicode".into());
}
if !c.launch_options.is_empty() {
return Some(format!("{} only applies to a new process", c.launch_options.join(", ")));
}
None
}
/// The running instance: holds the lock and receives other launches' paths.
pub struct Primary {
lock: Mutex<Option<File>>,
shared: Arc<Shared>,
}
struct Shared {
inbox: Mutex<Inbox>,
/// Set by [`Primary::shutdown`]: new connections are dropped, waiting ones refused.
closed: AtomicBool,
}
#[derive(Default)]
struct Inbox {
pending: Vec<Pending>,
ctx: Option<egui::Context>,
next_id: u64,
}
/// Paths waiting for the UI; `taken` tells the connection that sent them.
struct Pending {
id: u64,
paths: Vec<String>,
taken: SyncSender<()>,
}
impl Primary {
/// The queue the UI drains (see `Services::os_events`); a later launch wakes `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn {
let mut inbox = self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner);
if !inbox.pending.is_empty() {
ctx.request_repaint();
}
inbox.ctx = Some(ctx.clone());
drop(inbox);
let shared = Arc::clone(&self.shared);
Box::new(move || shared.take())
}
/// Stop taking files and release the lock: launches waiting on this one are refused and take
/// the lock over themselves. Call when the event loop has ended, and before relaunching.
pub fn shutdown(&self) {
self.shared.closed.store(true, Ordering::Release);
// Dropping the senders refuses every connection still waiting for the UI.
self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear();
drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take());
}
}
impl Drop for Primary {
fn drop(&mut self) {
self.shutdown();
}
}
impl Shared {
/// Everything queued, acknowledged to the senders while the inbox is locked (see `hand_over`).
fn take(&self) -> Vec<OsEvent> {
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
inbox
.pending
.drain(..)
.map(|p| {
let _ = p.taken.try_send(());
OsEvent::Open(p.paths)
})
.collect()
}
/// Queue `paths` for the UI and wait up to `wait` for it to take them. False (and nothing left
/// queued) when it didn't: the sender then retries or starts its own instance.
fn hand_over(&self, paths: Vec<String>, wait: Duration) -> bool {
let (taken, rx) = sync_channel(1);
let id = {
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
if self.closed.load(Ordering::Acquire) {
return false;
}
let id = inbox.next_id;
inbox.next_id = id.wrapping_add(1);
inbox.pending.push(Pending { id, paths, taken });
if let Some(ctx) = &inbox.ctx {
ctx.request_repaint();
}
id
};
if rx.recv_timeout(wait).is_ok() {
return true;
}
// Timed out or refused: withdraw, unless the UI took them in the meantime (it acknowledges
// while holding the lock, so the answer is settled here).
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
if rx.try_recv().is_ok() {
return true;
}
inbox.pending.retain(|p| p.id != id);
false
}
}
/// Become the running instance under `dir` or hand `paths` (absolute) to the one that is.
pub fn claim(dir: &Path, paths: &[String]) -> Launch {
claim_with(dir, paths, TIMING)
}
/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock
/// (free once a quitting or crashed primary is gone) and then the running primary.
pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
if let Err(e) = std::fs::create_dir_all(dir) {
return Launch::Standalone(format!("{}: {e}", dir.display()));
}
let lock_path = dir.join(LOCK_FILE);
let lock = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&lock_path) {
Ok(lock) => lock,
Err(e) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
};
let deadline = Instant::now() + timing.handoff;
let mut last = "no instance.json".to_string();
loop {
match lock.try_lock() {
Ok(()) => {
return match serve(dir, lock, timing) {
Ok(primary) => Launch::Primary(primary),
Err(e) => Launch::Standalone(e),
};
}
Err(std::fs::TryLockError::WouldBlock) => {}
Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
}
if let Some((port, token)) = read_info(dir) {
match send(port, &token, paths, deadline) {
Ok(()) => return Launch::Forwarded,
Err(e) => last = e,
}
}
if Instant::now() >= deadline {
return Launch::Standalone(format!("the running PhotoCraft didn't take the files ({last})"));
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
let token = generate_token().map_err(|e| e.to_string())?;
write_info(dir, port, &token)?;
let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) });
let listening = Arc::clone(&shared);
let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
let token = Arc::new(token);
let clients = Arc::new(AtomicUsize::new(0));
loop {
let stream = match listener.accept() {
Ok((stream, _)) => stream,
// Back off instead of spinning when accept keeps failing (e.g. out of descriptors).
Err(_) => {
std::thread::sleep(Duration::from_millis(100));
continue;
}
};
// Closing: the sender sees the connection drop and takes the lock over.
if listening.closed.load(Ordering::Acquire) {
continue;
}
// Each launch on its own thread, so one that connects and stalls doesn't hold the
// ones after it; past the cap a connection is dropped and that launch retries.
if clients.fetch_add(1, Ordering::AcqRel) >= MAX_CLIENTS {
clients.fetch_sub(1, Ordering::AcqRel);
continue;
}
let (token, shared, slot) = (Arc::clone(&token), Arc::clone(&listening), Arc::clone(&clients));
let spawned = std::thread::Builder::new().name("single-instance-client".into()).spawn(move || {
serve_client(stream, &token, &shared, timing);
slot.fetch_sub(1, Ordering::AcqRel);
});
if spawned.is_err() {
clients.fetch_sub(1, Ordering::AcqRel);
}
}
});
spawned.map_err(|e| format!("single-instance listener thread: {e}"))?;
Ok(Primary { lock: Mutex::new(Some(lock)), shared })
}
/// Read one request, hand its paths to the UI and answer `ok` only if the UI took them.
fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) {
let taken = match read_request(&stream, timing.request) {
Some(line) => match parse_request(&line, token) {
Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)),
None => false,
},
None => return,
};
let reply = if taken { json!({"ok": true}) } else { json!({"ok": false, "error": "not taken"}) };
let mut out = stream;
let _ = out.set_write_timeout(Some(timing.request));
let _ = writeln!(out, "{reply}").and_then(|()| out.flush());
}
/// A reader that fails once `until` passes, however slowly the bytes trickle in.
struct Deadline<'a> {
stream: &'a TcpStream,
until: Instant,
}
impl Read for Deadline<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero());
let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?;
self.stream.set_read_timeout(Some(left))?;
let mut stream = self.stream;
stream.read(buf)
}
}
/// The request line, if it arrives complete within `total`.
fn read_request(stream: &TcpStream, total: Duration) -> Option<String> {
let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total });
let mut line = String::new();
matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line)
}
/// Port and token, written to a temporary file and renamed so a reader never sees half of it.
/// On Unix the file is the user's alone (0600): another local user who read the token could make
/// this app open any path. (Windows keeps the per-user ACL of the settings folder.)
fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
let path = dir.join(INFO_FILE);
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string();
// A leftover from a crash could carry wider permissions; the mode applies only on create.
let _ = std::fs::remove_file(&tmp);
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let written = options.open(&tmp).and_then(|mut f| f.write_all(body.as_bytes())).and_then(|()| std::fs::rename(&tmp, &path));
written.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("{}: {e}", path.display())
})
}
fn read_info(dir: &Path) -> Option<(u16, String)> {
let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?;
let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
Some((port, v.get("token")?.as_str()?.to_string()))
}
/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI.
fn parse_request(line: &str, token: &str) -> Option<(Vec<String>, Duration)> {
let v: Value = serde_json::from_str(line).ok()?;
if !token_matches(token, v.get("token")?.as_str()?) {
return None;
}
let open = v.get("open")?.as_array()?;
if open.len() > MAX_PATHS {
return None;
}
let paths = open.iter().map(|p| p.as_str().map(str::to_string)).collect::<Option<Vec<_>>>()?;
let wait = Duration::from_millis(v.get("wait_ms").and_then(Value::as_u64).unwrap_or(0));
Some((paths, wait))
}
/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them.
fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?;
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?;
// The primary must answer before our deadline, so it waits a little less than we do.
let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX);
let request = json!({"token": token, "open": paths, "wait_ms": wait_ms});
stream.set_write_timeout(Some(left)).map_err(|e| e.to_string())?;
writeln!(stream, "{request}").and_then(|()| stream.flush()).map_err(|e| e.to_string())?;
let mut reader = BufReader::new(Deadline { stream: &stream, until: deadline });
let mut line = String::new();
match read_bounded_line(&mut reader, &mut line) {
Ok(LineRead::Line) => {}
Ok(_) => return Err("no reply".into()),
Err(e) => return Err(e.to_string()),
}
let ok = serde_json::from_str::<Value>(&line).ok().and_then(|v| v.get("ok")?.as_bool()) == Some(true);
if ok { Ok(()) } else { Err("not taken".into()) }
}
/// Absolute form of a command-line path, so the running instance (another working directory)
/// opens the same file.
pub fn absolute(path: &str) -> String {
std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::mpsc::channel;
const FAST: Timing = Timing { handoff: Duration::from_millis(1500), ack: Duration::from_millis(500), request: Duration::from_millis(800) };
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("photocraft-single-instance-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
/// Launch with `paths` on another thread (it blocks until the primary's UI takes them).
fn launch(dir: &Path, paths: &[&str], timing: Timing) -> std::thread::JoinHandle<Launch> {
let (dir, paths): (PathBuf, Vec<String>) = (dir.to_path_buf(), paths.iter().map(|p| p.to_string()).collect());
std::thread::spawn(move || claim_with(&dir, &paths, timing))
}
/// Play the UI: drain until `n` events arrived (or 5 s passed).
fn drain(ui: &mut OsEventsFn, n: usize) -> Vec<OsEvent> {
let mut got = Vec::new();
let deadline = Instant::now() + Duration::from_secs(5);
while got.len() < n && Instant::now() < deadline {
got.extend(ui());
std::thread::sleep(Duration::from_millis(10));
}
got
}
fn primary(dir: &Path) -> Primary {
match claim_with(dir, &[], FAST) {
Launch::Primary(p) => p,
_ => panic!("first launch should be primary"),
}
}
#[test]
fn second_launch_hands_its_paths_to_the_first() {
let dir = temp_dir("handoff");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let paths = ["C:\\art\\one.psd", "/home/a/two é.png"];
let second = launch(&dir, &paths, TIMING);
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.iter().map(|p| p.to_string()).collect())]);
assert!(matches!(second.join().unwrap(), Launch::Forwarded));
// A plain second start (no files) just brings the window forward.
let third = launch(&dir, &[], TIMING);
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(Vec::new())]);
assert!(matches!(third.join().unwrap(), Launch::Forwarded));
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn simultaneous_launches_all_land_in_one_instance() {
let dir = temp_dir("race");
let (tx, rx) = channel();
for i in 0..6 {
let (dir, tx) = (dir.clone(), tx.clone());
std::thread::spawn(move || {
let _ = tx.send(claim(&dir, &[format!("file{i}.psd")]));
});
}
drop(tx);
let (mut primaries, mut forwarded, mut events) = (Vec::new(), 0, Vec::new());
let mut ui: Option<OsEventsFn> = None;
let deadline = Instant::now() + Duration::from_secs(10);
while primaries.len() + forwarded < 6 && Instant::now() < deadline {
while let Ok(launch) = rx.try_recv() {
match launch {
Launch::Primary(p) => primaries.push(p),
Launch::Forwarded => forwarded += 1,
Launch::Standalone(reason) => panic!("standalone: {reason}"),
}
}
if ui.is_none()
&& let Some(p) = primaries.first()
{
ui = Some(p.connect(&egui::Context::default()));
}
if let Some(ui) = ui.as_mut() {
events.extend(ui());
}
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!((primaries.len(), forwarded, events.len()), (1, 5, 5));
drop(primaries);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_crashed_instance_leaves_nothing_in_the_way() {
let dir = temp_dir("stale");
std::fs::create_dir_all(&dir).unwrap();
// instance.json from a process that is gone; the lock is free.
std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap();
let primary = primary(&dir);
assert!(read_info(&dir).is_some_and(|(port, _)| port != 1));
drop(primary);
// Released with the handle, as on process exit.
assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Review of #3755, point 1 (and the quitting half of point 3): a launch waiting on a primary
/// that shuts down (closing, or about to relaunch itself in CPU mode) isn't told `ok`; it takes
/// the lock over and opens the file itself.
#[test]
fn a_launch_waiting_on_a_closing_instance_takes_over() {
let dir = temp_dir("closing");
let primary = primary(&dir);
// The UI never drains (it is shutting down, or never came up).
let waiting = launch(&dir, &["a.psd"], FAST);
std::thread::sleep(Duration::from_millis(200));
primary.shutdown();
assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary");
// After shutdown new connections are refused too.
let (port, token) = read_info(&dir).unwrap();
drop(primary);
let short = Instant::now() + Duration::from_millis(300);
assert!(send(port, &token, &["b.psd".into()], short).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
/// Review of #3755, point 3: a primary whose UI never takes the paths (hung) doesn't
/// acknowledge them, and doesn't keep them queued to open later as well.
#[test]
fn a_hung_instance_never_swallows_a_file() {
let dir = temp_dir("hung-ui");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let started = Instant::now();
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
assert!(started.elapsed() < FAST.handoff + Duration::from_secs(1), "{:?}", started.elapsed());
assert!(ui().is_empty(), "withdrawn, not opened later");
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn requests_without_the_token_or_malformed_are_rejected() {
let token = generate_token().unwrap();
let ok = parse_request(&json!({"token": token, "open": ["a"], "wait_ms": 250}).to_string(), &token);
assert_eq!(ok, Some((vec!["a".to_string()], Duration::from_millis(250))));
let wrong = generate_token().unwrap();
assert_eq!(parse_request(&json!({"token": wrong, "open": ["a"]}).to_string(), &token), None);
for bad in ["", "not json", "{}", r#"{"token":5}"#, &json!({"token": token}).to_string(), &json!({"token": token, "open": [1]}).to_string()] {
assert_eq!(parse_request(bad, &token), None, "{bad:?}");
}
let many: Vec<String> = (0..=MAX_PATHS).map(|i| i.to_string()).collect();
assert_eq!(parse_request(&json!({"token": token, "open": many}).to_string(), &token), None);
}
#[test]
fn a_stalled_client_does_not_hold_up_the_next_launch() {
let dir = temp_dir("stalled");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let (port, _) = read_info(&dir).unwrap();
// Connects and never sends a line (each would otherwise hold the listener).
let stalled: Vec<TcpStream> = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect();
let started = Instant::now();
let next = launch(&dir, &["b.psd"], FAST);
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(vec!["b.psd".into()])]);
assert!(matches!(next.join().unwrap(), Launch::Forwarded));
assert!(started.elapsed() < FAST.request, "took {:?}", started.elapsed());
drop(stalled);
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
/// A client that trickles a byte at a time can't keep its slot past the request deadline.
#[test]
fn a_trickling_client_is_cut_off_at_the_request_deadline() {
let dir = temp_dir("trickle");
let primary = primary(&dir);
let (port, _) = read_info(&dir).unwrap();
let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let started = Instant::now();
let mut cut_off = false;
while started.elapsed() < FAST.request * 4 {
if slow.write_all(b"{").is_err() {
cut_off = true;
break;
}
std::thread::sleep(Duration::from_millis(100));
}
assert!(cut_off, "still connected after {:?}", started.elapsed());
assert!(started.elapsed() < FAST.request * 3, "{:?}", started.elapsed());
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn the_token_file_is_private_to_the_user() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("mode");
std::fs::create_dir_all(&dir).unwrap();
// A wide leftover temporary file from a crashed start doesn't widen the new one.
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
std::fs::write(&tmp, "{}").unwrap();
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
write_info(&dir, 1, &generate_token().unwrap()).unwrap();
let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_unreachable_instance_falls_back_to_a_standalone_launch() {
let dir = temp_dir("unreachable");
std::fs::create_dir_all(&dir).unwrap();
// Someone holds the lock but nothing listens on the advertised port.
let held = File::create(dir.join(LOCK_FILE)).unwrap();
held.lock().unwrap();
let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
write_info(&dir, closed, &generate_token().unwrap()).unwrap();
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
drop(held);
let _ = std::fs::remove_dir_all(&dir);
}
/// Review of #3755, point 2 and the smaller points: who takes part in single-instance.
#[test]
fn only_per_user_launches_without_process_options_take_part() {
let base = LaunchContext {
per_user_settings: true,
new_instance_flag: false,
new_instance_env: None,
control: false,
unreadable_paths: false,
launch_options: &[],
};
assert_eq!(skip_reason(&base), None);
// A portable copy's folder or a PHOTOCRAFT_CONFIG_DIR may be shared between users.
assert!(skip_reason(&LaunchContext { per_user_settings: false, ..base }).is_some());
assert!(skip_reason(&LaunchContext { new_instance_flag: true, ..base }).is_some());
assert!(skip_reason(&LaunchContext { new_instance_env: Some("1"), ..base }).is_some());
assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some("0"), ..base }), None);
assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some(""), ..base }), None);
assert!(skip_reason(&LaunchContext { control: true, ..base }).is_some());
assert!(skip_reason(&LaunchContext { unreadable_paths: true, ..base }).is_some());
let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base });
assert!(reason.is_some_and(|r| r.contains("--safe-gpu")));
}
}
+12
View File
@@ -22,6 +22,18 @@ fn main() {
use photocraft_ui_egui::native_menu;
use serde_json::json;
// Without a harness nothing answers cargo-nextest's `--list`, so it ran the whole test as its
// listing and failed on the output. List the one test, as libtest's terse format would, and
// nothing for `--ignored` (nextest asks twice). Running it passes `<name> --exact`, which falls
// through to the test below.
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--list") {
if !args.iter().any(|a| a == "--ignored") {
println!("mac_menu_appkit: test");
}
return;
}
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
let nsapp = NSApplication::sharedApplication(mtm);
nsapp.finishLaunching();
+126 -14
View File
@@ -79,23 +79,48 @@ fn mirror(i: isize, n: usize) -> usize {
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
/// Planes smaller than this run their rows on one thread: the filter builds a whole pyramid per
/// reference (up to 96), and splitting its small levels into one-row tasks cost more in rayon's
/// scheduling than the rows themselves (a fifth of a Shadows render on 36 MP, measured).
const PAR_MIN: usize = 1 << 15;
/// [`par_rows`] for a plane, on one thread when it is small (see [`PAR_MIN`]).
fn rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) {
if buf.len() < PAR_MIN {
buf.chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| f(y, row));
} else {
par_rows(buf, w, 1, f);
}
}
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
fn blur_step(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
// Columns first at the kept rows, then rows at the kept columns.
// Columns first at the kept rows, then rows at the kept columns. Each sum adds the same terms
// in the same order as a `mirror` per tap would, so the result is bit for bit the same; the
// source rows are found once per row and the columns mirrored only near the edges.
let mut tmp = vec![0.0f32; p.w * h2];
par_rows(&mut tmp, p.w, 1, |y2, row| {
rows(&mut tmp, p.w, |y2, row| {
let y = (y2 * step) as isize;
let src: [&[f32]; 5] = std::array::from_fn(|k| {
let r = mirror(y + k as isize - 2, p.h);
&p.v[r * p.w..(r + 1) * p.w]
});
for (x, o) in row.iter_mut().enumerate() {
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
*o = (0..5).map(|k| TAPS[k] * src[k][x]).sum();
}
});
let mut out = vec![0.0f32; w2 * h2];
par_rows(&mut out, w2, 1, |y2, row| {
rows(&mut out, w2, |y2, row| {
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
for (x2, o) in row.iter_mut().enumerate() {
let x = (x2 * step) as isize;
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
let x = x2 * step;
*o = if x >= 2 && x + 2 < p.w {
let s = &src[x - 2..x + 3];
(0..5).map(|k| TAPS[k] * s[k]).sum()
} else {
(0..5).map(|k| TAPS[k] * src[mirror(x as isize + k as isize - 2, p.w)]).sum()
};
}
});
Plane { w: w2, h: h2, v: out }
@@ -107,14 +132,45 @@ fn down(p: &Plane) -> Plane {
}
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
///
/// Three samples in four are zero, so their taps are skipped instead of blurring a zero-filled
/// plane: the nonzero terms are added in the same order (skipping `+0.0` terms changes at most the
/// sign of a zero, and the filter only adds and exponentiates these values).
fn up(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
}
let (lh, lw) = (p.h.min(h.div_ceil(2)), p.w.min(w.div_ceil(2)));
// The source sample behind index `i` of a zero-stuffed axis of length `n` holding `lim` samples.
let src = |i: isize, n: usize, lim: usize| {
let m = mirror(i, n);
(m.is_multiple_of(2) && m / 2 < lim).then_some(m / 2)
};
// Columns first, only at the even columns (the odd ones are all zero).
let mut tmp = vec![0.0f32; lw * h];
if lw > 0 {
rows(&mut tmp, lw, |y, row| {
let taps: [Option<&[f32]>; 5] = std::array::from_fn(|k| src(y as isize + k as isize - 2, h, lh).map(|r| &p.v[r * p.w..r * p.w + lw]));
for (j, o) in row.iter_mut().enumerate() {
*o = (0..5).filter_map(|k| taps[k].map(|s| TAPS[k] * (s[j] * 4.0))).sum();
}
});
}
blur_step(&z, 1)
let mut out = vec![0.0f32; w * h];
rows(&mut out, w, |y, row| {
let t = &tmp[y * lw..(y + 1) * lw];
for (x, o) in row.iter_mut().enumerate() {
*o = if x >= 2 && x + 2 < w {
// Away from the edges no tap mirrors.
(0..5)
.filter_map(|k| {
let i = x + k - 2;
(i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * t[i / 2])
})
.sum()
} else {
(0..5).filter_map(|k| src(x as isize + k as isize - 2, w, lw).map(|j| TAPS[k] * t[j])).sum()
};
}
});
Plane { w, h, v: out }
}
/// Levels down to about 2 pixels on the short side.
@@ -154,7 +210,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
for k in 0..refs {
let r = lo + step * k as f32;
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
par_rows(&mut m.v, u.w, 1, |_, row| {
rows(&mut m.v, u.w, |_, row| {
for v in row.iter_mut() {
*v = remap(*v, r);
}
@@ -163,7 +219,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
for l in 0..n - 1 {
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
par_rows(&mut lap[l], lw, 1, |y, row| {
rows(&mut lap[l], lw, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
@@ -337,6 +393,62 @@ mod tests {
const SRGB: Encoding = Encoding(0.0);
/// The plain blur: a `mirror` per tap over the whole plane, the reference for [`blur_step`].
fn blur_step_reference(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
let mut tmp = vec![0.0f32; p.w * h2];
for y2 in 0..h2 {
for x in 0..p.w {
tmp[y2 * p.w + x] = (0..5).map(|k| TAPS[k] * p.v[mirror((y2 * step) as isize + k as isize - 2, p.h) * p.w + x]).sum();
}
}
let mut out = vec![0.0f32; w2 * h2];
for y2 in 0..h2 {
for x2 in 0..w2 {
out[y2 * w2 + x2] = (0..5).map(|k| TAPS[k] * tmp[y2 * p.w + mirror((x2 * step) as isize + k as isize - 2, p.w)]).sum();
}
}
Plane { w: w2, h: h2, v: out }
}
/// The plain upsampling: a zero-stuffed plane blurred whole, the reference for [`up`].
fn up_reference(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
}
}
blur_step_reference(&z, 1)
}
fn noise_plane(w: usize, h: usize, seed: u32) -> Plane {
let mut s = seed.wrapping_mul(2_654_435_761).max(1);
let v = (0..w * h)
.map(|_| {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
(s % 20_000) as f32 / 1000.0 - 16.0
})
.collect();
Plane { w, h, v }
}
#[test]
fn the_fast_pyramid_matches_the_plain_one_exactly() {
// Odd, even, tiny and large-enough-to-run-in-parallel sizes, as the pyramid's levels are.
for (i, (w, h)) in [(1, 1), (1, 7), (2, 2), (3, 5), (5, 3), (8, 6), (37, 23), (64, 64), (301, 211)].into_iter().enumerate() {
let p = noise_plane(w, h, i as u32 + 1);
for step in [1, 2] {
assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}");
}
for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] {
assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
}
}
}
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-automation"
description = "MCP server (rmcp) over the Photocraft engine, plus a bridge to a running GUI"
description = "MCP server (rmcp) over the PhotoCraft engine, plus a bridge to a running GUI"
version.workspace = true
edition.workspace = true
license.workspace = true
+5 -4
View File
@@ -325,10 +325,11 @@ pub struct UiSetParams {
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
/// active layer's storage tiles). layerFilter (an object patching the Layers panel filter:
/// by: kind/name/mode; kinds: a list of pixel/adjustment/type/shape/smart; name: a string;
/// mode: a blend mode name such as Multiply or Pass Through). Omitted filter fields keep
/// their current values. Other fields are an error.
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
/// over this channel). Other fields are an error.
pub fields: Value,
}
+10
View File
@@ -369,6 +369,10 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"actions.save" | "swatches.export" => params.get("path").is_some(),
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
"edit.keyboardShortcuts" => {
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
}
"plugin.reload" => true,
_ => false,
}
@@ -712,6 +716,12 @@ mod tests {
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
for key in ["importKys", "exportKys", "summarize"] {
assert!(ks(serde_json::json!({ key: {"path": "/outside/set.kys"} })).is_err(), "{key}");
}
assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err());
assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err());
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err());
@@ -0,0 +1,259 @@
//! Stored smart-filter parameters must not grant automation ambient filesystem access.
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step};
use photocraft_doc::{Document, LayerContent, LayerId};
use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds};
use serde_json::{Value, json};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&base).unwrap();
std::fs::create_dir(base.join("workspace")).unwrap();
std::fs::create_dir(base.join("outside")).unwrap();
let f = Self(base);
std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap();
std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap();
std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap();
std::fs::write(f.outside("black.cube"), cube(0)).unwrap();
std::fs::write(f.outside("white.cube"), cube(1)).unwrap();
f
}
fn root(&self) -> PathBuf {
self.0.join("workspace")
}
fn outside(&self, name: &str) -> PathBuf {
self.0.join("outside").join(name)
}
fn headless(&self) -> Headless {
Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap())
}
fn setup(&self, lookup: bool) -> (Headless, LayerId) {
let mut h = self.headless();
h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap();
h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap();
let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap());
let (id, params) = if lookup {
("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"}))
} else {
("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0}))
};
h.session.execute(id, params).unwrap();
(h, layer)
}
fn saved_filter(&self, lookup: bool) -> (Document, LayerId) {
let (h, layer) = self.setup(lookup);
let mut doc = (*h.session.active().unwrap().doc).clone();
let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") };
sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" }));
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap();
(doc, layer)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn png(value: impl Fn(u32) -> u8) -> Vec<u8> {
let mut pixels = Vec::new();
for _ in 0..32 {
for x in 0..32 {
pixels.extend([value(x); 3]);
}
}
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap();
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()
}
fn cube(value: u8) -> String {
format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8))
}
fn path_params(lookup: bool, path: &Path) -> Value {
if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) }
}
fn assert_denied<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
match result {
Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"),
Ok(value) => panic!("ambient file access was accepted: {value:?}"),
}
}
#[test]
fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() {
for lookup in [false, true] {
let f = Fixture::new();
let (mut h, layer) = f.setup(lookup);
let before = h.render_png(None, 64).unwrap();
let doc = h.session.active().unwrap().doc.clone();
let journal = h.session.journal.len();
for name in if lookup { ["black.cube", "white.cube"] } else { ["black.png", "white.png"] } {
let params = path_params(lookup, &f.outside(name));
let id = if lookup { "image.adjustments.colorLookup" } else { "filter.distort.displace" };
assert_denied(h.session.execute(id, params.clone()));
assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": {"layer": layer.0, "params": params}})));
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
assert_eq!(h.session.journal.len(), journal);
assert_eq!(h.render_png(None, 64).unwrap(), before);
}
// Safe numeric and embedded-data edits remain usable.
let safe = if lookup { json!({"data": cube(0), "fileName": "embedded.cube"}) } else { json!({"horizontal": 10}) };
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
}
}
#[test]
fn action_playback_cannot_hide_a_smart_filter_path_edit() {
let f = Fixture::new();
let (mut h, layer) = f.setup(false);
let doc = h.session.active().unwrap().doc.clone();
h.session.actions.list.push(Action::new(
"edit filter",
vec![("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "params": path_params(false, &f.outside("white.png"))}))],
));
let result = h.session.execute("actions.play", json!({"action": "edit filter"})).unwrap();
assert!(result["failed"]["error"].as_str().unwrap().contains("ambient filesystem"), "{result}");
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
}
#[test]
fn loaded_filters_are_checked_after_merging_even_when_the_edit_contains_no_path() {
for lookup in [false, true] {
let f = Fixture::new();
let (_, layer) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let doc = h.session.active().unwrap().doc.clone();
assert_denied(h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": {"horizontal": 10, "dither": false}})));
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
// Removing an ambient input makes the filter usable again.
let safe = if lookup { json!({"file": "", "lut": "none"}) } else { json!({"mapPath": ""}) };
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
}
}
#[test]
fn loaded_filters_cannot_read_on_rerender_or_reuse_a_trusted_cached_stage() {
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
for warm_cache in [false, true] {
if warm_cache {
let mut trusted = doc.clone();
assert!(smart_cmds::refresh(&mut trusted, layer).unwrap());
}
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let before = h.session.active().unwrap().doc.clone();
assert_denied(h.session.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
}
}
}
#[test]
fn background_rerender_keeps_the_desktop_request_authority() {
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut s = Session::new();
s.add_document(doc, None);
s.authorize = Some(authorize_desktop_engine_step);
let before = s.active().unwrap().doc.clone();
let started = s.start("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
// The desktop restores UI authority as soon as the automation request returns.
s.authorize = None;
let Started::Job(job) = started else { panic!("expected a background render") };
assert_denied(s.wait_job(job));
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
// The worker's restriction must not leak into subsequent trusted UI work.
s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
}
}
#[test]
fn transferring_stored_filters_cannot_read_outside_files() {
for lookup in [false, true] {
let f = Fixture::new();
let (_, from) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let to = h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap();
let before = h.session.active().unwrap().doc.clone();
for copy in [false, true] {
assert_denied(h.session.execute("layer.smartFilter.transfer", json!({"from": from.0, "to": to, "copy": copy})));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
}
}
}
#[test]
fn trusted_parameter_edits_still_read_maps_and_luts_after_a_denied_request() {
for lookup in [false, true] {
let f = Fixture::new();
let (mut h, layer) = f.setup(lookup);
let edit = |path: &Path| json!({"layer": layer.0, "params": path_params(lookup, path)});
assert_denied(h.session.execute("layer.smartFilter.setParams", edit(&f.outside("white.png"))));
let mut trusted = Headless::trusted_local();
trusted.session.add_document((*h.session.active().unwrap().doc).clone(), None);
let mut renders = Vec::new();
for name in if lookup { ["black.cube", "white.cube", "black.cube"] } else { ["black.png", "white.png", "black.png"] } {
trusted.session.execute("layer.smartFilter.setParams", edit(&f.outside(name))).unwrap();
renders.push(trusted.render_png(None, 64).unwrap());
}
assert_ne!(renders[0], renders[1], "trusted path-based filter still works");
assert_eq!(renders[0], renders[2], "trusted rendering remains deterministic");
}
}
#[test]
fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_unfiltered() {
// link-watch and transform paths re-render through Session::edit, not Session::execute or
// jobs::run; the gate must reach them and the denial must not silently pass pixels through
// into the process-wide stack cache.
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let before = h.session.active().unwrap().doc.clone();
// Session::edit is the outside-execute mutation path (an edit closure the UI or a
// linked-file change runs); the stored filter must be denied there too.
assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) {
Ok(_) => Ok(()),
Err(e) => Err(e),
}));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
// A trusted session (no gate) still renders the same document.
let mut trusted = Session::new();
trusted.add_document(doc, None);
let mut d = (*trusted.active().unwrap().doc).clone();
assert!(smart_cmds::refresh(&mut d, layer).is_ok());
}
}
#[test]
fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() {
// The denial path must fail the render before any stage is cached process-wide.
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut s = Session::new();
s.add_document(doc, None);
s.authorize = Some(authorize_desktop_engine_step);
let bytes = smart_cmds::stack_cache_bytes();
assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Photocraft colour management: a pure-Rust ICC v2/v4 colour management module.
//! PhotoCraft colour management: a pure-Rust ICC v2/v4 colour management module.
//!
//! * [`Profile`] parses ICC profiles (matrix/TRC RGB, gray TRC, `mft1`/`mft2`/`mAB `/`mBA `
//! LUT profiles incl. CMYK↔Lab, `curv`/`para` curves) and writes ICC v4 profiles.
+1 -1
View File
@@ -245,7 +245,7 @@ fn smooth(x: f32) -> f32 {
x * x * (3.0 - 2.0 * x)
}
/// Generated looks shipped with Photocraft (procedural, CC0): `(id, label)`.
/// Generated looks shipped with PhotoCraft (procedural, CC0): `(id, label)`.
pub const BUILTIN: [(&str, &str); 8] = [
("warm", "Warm Filter"),
("cool", "Cool Filter"),
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39 …) are proprietary, and the
//! freely downloadable characterisation-based profiles come with redistribution conditions, so
//! Photocraft ships a CMYK profile it generates itself from a documented printer model:
//! PhotoCraft ships a CMYK profile it generates itself from a documented printer model:
//!
//! * **Forward model** (CMYK → XYZ): Yule–Nielsen modified Neugebauer (n = 2) over the 16
//! ink overprints, with Demichel coverage weights and a parabolic tone value increase (dot
+16 -1
View File
@@ -66,6 +66,21 @@ impl<'a> Reader<'a> {
}
}
/// PAM ends its header with an LF-terminated line, unlike P4–P6's single separator.
fn pam_endhdr(&mut self) -> Result<(), CodecError> {
while let Some(&c) = self.b.get(self.pos) {
self.pos += 1;
if c == b'\n' {
// Stop at the first LF: whitespace and '#' after it are raster bytes.
return Ok(());
}
if !c.is_ascii_whitespace() {
return Err(err("unexpected data after PAM ENDHDR"));
}
}
Err(err("unterminated PAM ENDHDR line"))
}
fn rest(&self) -> &'a [u8] {
&self.b[self.pos.min(self.b.len())..]
}
@@ -269,7 +284,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
_ => return Err(err("unknown PAM header field")),
}
}
r.single_ws()?;
r.pam_endhdr()?;
let (w, h, depth, maxval) = match (w, h, depth, maxval) {
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
_ => return Err(err("incomplete PAM header")),
+82
View File
@@ -0,0 +1,82 @@
use photocraft_codecs::{ChannelLayout, CodecError, Format, SampleType, decode, decode_as};
const PAM_GRAY: &[u8] = b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 1\nMAXVAL 255\nENDHDR";
const ENDINGS: &[&[u8]] = &[b"\n", b" \n", b"\t \n", b"\r\n", b" \t\r\n"];
#[test]
fn pam_consumes_the_complete_endhdr_line() {
for ending in ENDINGS {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(ending);
bytes.push(0x80);
let img = decode(&bytes).unwrap();
assert_eq!(img.dimensions(), (1, 1));
assert_eq!(img.layout(), ChannelLayout::Gray);
assert_eq!(img.data(), &[0x80], "ENDHDR ending {ending:?}");
}
}
#[test]
fn pam_preserves_every_possible_first_raster_byte() {
// In particular, LF, CR, space, tab and '#' must not be skipped as header syntax.
for ending in ENDINGS {
for first in 0..=255u8 {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(ending);
bytes.push(first);
assert_eq!(decode(&bytes).unwrap().data(), &[first], "ENDHDR ending {ending:?}, raster byte {first}");
}
}
}
#[test]
fn pam_crlf_header_preserves_u16_samples_and_alpha() {
for ending in ENDINGS {
let mut bytes = b"P7\r\nWIDTH 1\r\nHEIGHT 1\r\nDEPTH 2\r\nMAXVAL 65535\r\nTUPLTYPE GRAYSCALE_ALPHA\r\nENDHDR".to_vec();
bytes.extend_from_slice(ending);
bytes.extend_from_slice(b" \n#\r");
let img = decode(&bytes).unwrap();
assert_eq!(img.layout(), ChannelLayout::GrayA);
assert_eq!(img.sample_type(), SampleType::U16);
assert_eq!(img.to_u16_samples().unwrap(), vec![0x200a, 0x230d], "ENDHDR ending {ending:?}");
}
}
#[test]
fn pam_rejects_unterminated_or_invalid_endhdr_lines() {
for suffix in [b"".as_slice(), b" ", b"\r", b" \t\r", b" \x80", b" \x80\n\x7f", b"#comment\n\x7f"] {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(suffix);
assert!(matches!(decode_as(Format::Pnm, &bytes), Err(CodecError::Malformed { .. })), "ENDHDR suffix {suffix:?}");
}
}
#[test]
fn pam_truncation_never_uses_header_whitespace_as_pixels() {
for ending in ENDINGS {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(ending);
bytes.push(0x80);
for cut in 0..bytes.len() {
assert!(decode_as(Format::Pnm, &bytes[..cut]).is_err(), "ENDHDR ending {ending:?}, truncated at {cut}");
}
}
}
#[test]
fn binary_pnm_keeps_its_single_whitespace_separator() {
for separator in *b" \t\r\n" {
for first in *b" \t\r\n#" {
for (header, channels) in [(b"P5\n1 1\n255".as_slice(), 1), (b"P6\n1 1\n255", 3)] {
let mut bytes = header.to_vec();
bytes.push(separator);
bytes.extend(std::iter::repeat_n(first, channels));
assert_eq!(decode(&bytes).unwrap().data(), vec![first; channels]);
}
let mut bytes = b"P4\n8 1".to_vec();
bytes.extend_from_slice(&[separator, first]);
let expected: Vec<u8> = (0..8).map(|bit| if first & (0x80 >> bit) == 0 { 255 } else { 0 }).collect();
assert_eq!(decode(&bytes).unwrap().data(), expected);
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! The Photocraft document model: pure data, no rendering, no UI.
//! The PhotoCraft document model: pure data, no rendering, no UI.
//!
//! Layer order: inside every group (and the root), `children[0]` is the **bottom** layer, matching
//! compositing order and the PSD file order. UIs display the list reversed.
+4 -3
View File
@@ -528,7 +528,7 @@ fn chosen_artboards(doc: &Document, p: &Value) -> Vec<LayerId> {
}
}
/// File › Export › Artboards to Files: `<prefix>_<artboard name>.<format>` per board.
/// File › Export › Artboards to Files: `<prefix>_<index:04>_<artboard name>.<format>` per board.
fn artboards_to_files(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export.artboardsToFiles";
let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string();
@@ -538,8 +538,9 @@ fn artboards_to_files(s: &mut Session, p: &Value) -> Result<Value> {
let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
let doc = d.doc.clone();
let mut files = Vec::new();
for one in chosen_documents(&doc, p)? {
let name = if prefix.is_empty() { sanitize(&one.name) } else { format!("{}_{}", sanitize(&prefix), sanitize(&one.name)) };
for (i, one) in chosen_documents(&doc, p)?.into_iter().enumerate() {
// Names may be identical, or collide after sanitizing or filesystem case folding.
let name = if prefix.is_empty() { format!("{i:04}_{}", sanitize(&one.name)) } else { format!("{}_{i:04}_{}", sanitize(&prefix), sanitize(&one.name)) };
let path = join(&dir, &format!("{name}.{format}"));
save_doc(&one, &path, save)?;
files.push(path);
+2 -2
View File
@@ -146,8 +146,8 @@ fn export_to_files_and_pdf() {
let r = s.execute("file.export.artboardsToFiles", json!({"dir": d, "prefix": "site"})).unwrap();
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
assert_eq!(files.len(), 2);
assert!(files[0].ends_with("site_Empty_One.png"), "{files:?}");
assert!(files[1].ends_with("site_Red Board.png"), "{files:?}");
assert!(files[0].ends_with("site_0000_Empty_One.png"), "{files:?}");
assert!(files[1].ends_with("site_0001_Red Board.png"), "{files:?}");
let img = photocraft_io::import("x.png", &std::fs::read(&files[1]).unwrap()).unwrap().document;
assert_eq!((img.size.width, img.size.height), (10, 10));
let img2 = photocraft_io::import("x.png", &std::fs::read(&files[0]).unwrap()).unwrap().document;
+6 -2
View File
@@ -863,9 +863,13 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
}
// Paint amount = darkness × alpha × selection (black = full paint, like Photoshop).
let (w, h) = (area.width() as usize, area.height() as usize);
let mut v = vec![0.0f32; w * h];
// The sampled tip is one f32 buffer over the whole selection; a large selection must fail
// gracefully instead of aborting the process on allocation, so reserve it fallibly.
let mut v = crate::allocation::filled(w * h, 0.0, "defining a brush from the selection")?;
let sc = sel.channels();
let sv = sel.read_region(area);
// The selection mask is a second buffer of the same size; it must fail gracefully too
// (review of #5469), so read it through the fallible allocator.
let sv = crate::allocation::read_region(sel, area, "defining a brush from the selection")?;
for y in 0..h {
for x in 0..w {
let c = surf.rgba(area.x0 + x as i32, area.y0 + y as i32);
+20
View File
@@ -442,6 +442,26 @@ fn define_brush_from_selection() {
assert!(rgba(&s, 30, 32)[3] > 0.9);
}
#[test]
fn define_brush_from_selection_survives_allocation_failure() {
// The sampled tip is one f32 buffer over the whole selection, and the selection mask is a
// second of the same size. Both are reserved through the fallible allocator, so an allocation
// failure must be a graceful error (brush, presets, history and document untouched), never a
// panic or an OOM abort.
let mut s = session(40, 40);
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
let brush_before = s.tools.brush.clone();
let presets_before = s.tools.presets.clone();
let history_before = s.active().unwrap().history.past_len();
crate::allocation::fail_next_for_test();
let result = s.execute("brush.defineFromSelection", json!({"name": "Blob"}));
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
assert!(result.is_err(), "an allocation failure must be reported, not panic");
assert_eq!(s.tools.brush, brush_before, "the current brush is unchanged");
assert_eq!(s.tools.presets, presets_before, "the preset list is unchanged");
assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded");
}
#[test]
fn works_on_cmyk_and_16_bit() {
for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] {
+16 -10
View File
@@ -174,8 +174,9 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
/// Runs filter command `id` with JSON `params` on `surf` (the pixel function behind every
/// `filter.*` command; smart filters re-run it on re-render). `bounds` frames distortions;
/// `selection` limits the result. Like layer filters, neighbourhood filters repeat the edge pixels
/// of `canvas` ∪ the surface's content instead of fading in transparency, and the output is
/// clipped to it. `None` for ids that aren't pixel filters.
/// `None` for ids that aren't pixel filters. `Err` when the requesting session's authority denies
/// the filter's params (an ambient path the session may not read): the caller must surface the
/// error rather than render unfiltered pixels.
pub fn apply_filter_to_surface(
id: &str,
params: &Value,
@@ -183,31 +184,36 @@ pub fn apply_filter_to_surface(
bounds: photocraft_geom::Rect,
selection: Option<&photocraft_raster::Surface>,
canvas: photocraft_geom::Rect,
) -> Option<photocraft_raster::Surface> {
) -> Result<Option<photocraft_raster::Surface>> {
// Replays bypass command dispatch; nested paths must still obey the requesting session's
// authority, including adjustment LUTs and plug-ins, before any resolver can read them.
crate::jobs::authorize_render_command(id, params)?;
// Liquify / Puppet Warp / Perspective Warp smart filters (distort_cmds).
if let Some(out) = crate::distort_cmds::apply_to_surface(id, params, surf, canvas) {
return Some(out);
return Ok(Some(out));
}
// Lens Correction / Adaptive Wide Angle / Camera Raw smart filters (lens_cmds).
if let Some(out) = crate::lens_cmds::apply_to_surface(id, params, surf, canvas) {
return Some(out);
return Ok(Some(out));
}
// Image › Adjustments recorded as smart filters (Levels, Curves, Shadows/Highlights… from PSD).
if let Some(kind) = id.strip_prefix("image.adjustments.") {
return crate::adjust_cmds::adjust_as_filter(kind, params, surf);
return Ok(crate::adjust_cmds::adjust_as_filter(kind, params, surf));
}
// WebAssembly plug-in smart filters (plugin_cmds).
if id == crate::plugin_cmds::RUN {
return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas);
return Ok(crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas));
}
let mut fp = params_for(id, params)?;
let Some(mut fp) = params_for(id, params) else { return Ok(None) };
// A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an
// unknown filter; the command itself already refused an unreadable map when it was added.
crate::filters_ext::resolve_map_path(&mut fp, params).ok()?;
if crate::filters_ext::resolve_map_path(&mut fp, params).is_err() {
return Ok(None);
}
let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds);
let content = surf.content_bounds();
let area = algo::output_area(&fp, content, bounds, sel_bounds);
Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content)))
Ok(Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content))))
}
pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), String> {
+43 -11
View File
@@ -29,6 +29,43 @@ use serde_json::{Value, json};
use crate::{EngineError, Result, Session};
type Authorization = fn(&str, &Value) -> Result<()>;
thread_local! {
// Smart-filter rendering has no Session argument. Carry the requesting session's gate
// through synchronous rendering and explicitly reinstall it on background workers.
static RENDER_AUTHORIZATION: std::cell::Cell<Option<Authorization>> = const { std::cell::Cell::new(None) };
}
pub(crate) struct AuthorizationScope(Option<Authorization>);
impl AuthorizationScope {
fn enter(gate: Option<Authorization>) -> Self {
Self(RENDER_AUTHORIZATION.with(|current| current.replace(gate.or(current.get()))))
}
}
impl Drop for AuthorizationScope {
fn drop(&mut self) {
RENDER_AUTHORIZATION.with(|current| current.set(self.0));
}
}
/// [`AuthorizationScope`] entered with `gate` (or the one already installed, so trusted work on
/// this thread keeps ambient paths usable). [`Session::edit`] and the job paths use this to carry
/// the requesting session's authority into every smart-object re-render.
pub(crate) fn render_authorization_scope(gate: Option<Authorization>) -> AuthorizationScope {
AuthorizationScope::enter(gate)
}
/// Recheck a stored filter's command and parameters before evaluating it or using its cache.
pub(crate) fn authorize_render_command(id: &str, params: &Value) -> Result<()> {
match RENDER_AUTHORIZATION.with(std::cell::Cell::get) {
Some(gate) => gate(id, params),
None => Ok(()),
}
}
/// Identifies one background job for the session's lifetime.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
pub struct JobId(pub u64);
@@ -284,6 +321,8 @@ pub fn run<T: Send + 'static>(
work: impl FnOnce(&JobCtx) -> Result<T> + Send + 'static,
apply: impl FnOnce(&mut Session, T) -> Result<Value> + Send + 'static,
) -> Result<Value> {
let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get));
let _authorization = AuthorizationScope::enter(authorization);
if !s.jobs.spawn || cfg!(target_arch = "wasm32") {
let t = work(&s.jobs.inline.clone().unwrap_or_default())?;
return apply(s, t);
@@ -299,6 +338,7 @@ pub fn run<T: Send + 'static>(
let ctx = JobCtx::new();
let (tx, rx) = std::sync::mpsc::channel::<Result<Payload>>();
let erased: Apply = Box::new(move |s, any| {
let _authorization = AuthorizationScope::enter(authorization);
let t = any.downcast::<T>().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?;
apply(s, *t)
});
@@ -311,6 +351,7 @@ pub fn run<T: Send + 'static>(
// The work closure is moved into the thread. If spawning fails (thread limits), there is
// no way to get it back, so the error is reported instead.
let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", id.0)).spawn(move || {
let _authorization = AuthorizationScope::enter(authorization);
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload)))
.unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged"))));
// The receiver is gone when the job was cancelled: nothing to report.
@@ -652,6 +693,7 @@ impl Session {
if let Some(gate) = self.authorize {
gate(id, &params)?;
}
let _authorization = AuthorizationScope::enter(self.authorize);
// Last Filter runs as the filter it repeats: journaled, recorded in an action, faded and
// run as a job like that filter, so a recorded step replays the same filter anywhere
// (#3089), as Transform Again records its transform (#1440). With no filter yet it falls
@@ -671,17 +713,7 @@ impl Session {
return Ok(Started::Done(v));
}
// Pixel commands follow the Channels panel target unless the caller names one.
let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone()));
if let Err(why) = self.explicit_layer_enabled(spec, &params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
return Err(EngineError::Disabled(id.to_string(), why));
}
if let Some(why) = self.job_conflict(id, spec.journal) {
return Err(EngineError::Disabled(id.to_string(), why));
}
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
if let Some(why) = crate::hidden_target::refusal(self, id, &params) {
return Err(EngineError::Other(why.into()));
}
let run_params = self.admit(id, spec, &params)?;
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
self.jobs.spawn = background;
+14 -13
View File
@@ -160,12 +160,14 @@ fn key(label: &str) -> String {
l.trim_end_matches('…').trim().to_lowercase()
}
/// The engine's own candidates: every command in a menu, in command order.
/// The engine's own candidates: every command in a menu (the engine's, then the shell's), in
/// command order.
pub fn command_candidates() -> Vec<Candidate> {
crate::command_specs()
.iter()
.filter(|c| !c.menu.is_empty())
.map(|c| Candidate { id: c.id.to_string(), label: c.label.to_string(), default: c.shortcut.map(str::to_string) })
let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut));
engine
.chain(crate::shell_commands::SHELL_COMMANDS.iter().copied())
.filter(|c| !c.2.is_empty())
.map(|(id, label, _, default)| Candidate { id: id.to_string(), label: label.to_string(), default: default.map(str::to_string) })
.collect()
}
@@ -219,8 +221,8 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, set: &KysSet) -> Pl
}
/// The tool keys of `set` matched to `tools` (our tool id and Photoshop's name for it, which is
/// ours too: "Move Tool"): tool id → key (a single letter, upper case), and the names with no
/// tool here. A row PhotoCraft wrote names the tool by id. A key that isn't one letter is skipped.
/// ours too: "Move Tool"): tool id → key (normalised: a letter, or an F-key), and the names with no
/// tool here. A row PhotoCraft wrote names the tool by id. A key with modifiers is skipped.
pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set: &KysSet) -> (BTreeMap<String, String>, Vec<String>) {
let tools: Vec<(&str, &str)> = tools.into_iter().collect();
let mut keys = BTreeMap::new();
@@ -232,11 +234,10 @@ pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set:
unknown.push(row.name.clone());
continue;
};
let mut letters = row.shortcuts.first().map(|k| k.trim()).unwrap_or_default().chars();
if let (Some(c), None) = (letters.next(), letters.next())
&& c.is_ascii_alphanumeric()
{
keys.insert((*id).to_string(), c.to_ascii_uppercase().to_string());
// One key without modifiers (Photoshop's letters; our tools may also take an F-key).
let key = row.shortcuts.first().and_then(|k| normalize_shortcut(k.trim()));
if let Some(k) = key.filter(|k| !k.contains('+') || k == "+") {
keys.insert((*id).to_string(), k);
}
}
(keys, unknown)
@@ -448,7 +449,7 @@ mod tests {
assert_eq!(keys.get("tools.select.move").map(String::as_str), Some("V"));
assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("N"), "case-insensitive, upper-cased");
assert_eq!(keys.get("tools.select.brush").map(String::as_str), Some("B"), "by pc-id");
assert!(!keys.contains_key("tools.select.lasso"), "a tool key is one letter");
assert!(!keys.contains_key("tools.select.lasso"), "a tool key has no modifiers");
assert_eq!(unknown, vec!["Imaginary Tool".to_string()]);
// Our own export reads back by id.
let out = write("T", &[Entry { id: "tools.select.pencil".into(), label: "Pencil Tool".into(), tool: true, keys: vec!["F".into()] }]);
+9 -1
View File
@@ -1487,7 +1487,15 @@ pub fn specs() -> Vec<CommandSpec> {
link_layers(s)
}),
spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)),
spec!("layer.new.groupFromLayers", "Group from Layers…", &["Layer", "New"], None, r##"{"name":str?}"##, has_layer, group_layers),
spec!(
"layer.new.groupFromLayers",
"Group from Layers…",
&["Layer", "New"],
None,
r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##,
has_layer,
group_layers
),
spec!("layer.arrange.reverse", "Reverse", &["Layer", "Arrange"], None, "{}", two_plus, |s, _| reverse(s)),
spec!(
"layer.lockLayers",
+59
View File
@@ -103,6 +103,7 @@ pub mod sample_cmds;
pub mod sampler_cmds;
pub mod select_extra_cmds;
pub mod selection_cmds;
pub mod shell_commands;
pub mod shortcut_sets;
pub mod slice_cmds;
pub mod smart_cmds;
@@ -421,6 +422,14 @@ pub struct CopiedStyle {
pub mode: photocraft_color::ColorMode,
}
/// The commands whose [`Session::precheck`] is complete: it accepts what `execute` does (Free
/// Transform applies its box before these, and only when they will run), but for the group
/// nesting limit ([`photocraft_doc::MAX_GROUP_DEPTH`]): grouping layers already that deep is
/// refused only as the edit is made. Layer via Copy and
/// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group
/// or fill layer) is known only when they run.
pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"];
#[derive(Default)]
pub struct Session {
docs: Vec<DocState>,
@@ -659,6 +668,53 @@ impl Session {
self.live_commit = None;
}
/// Check a call without running it: the command exists, its params have the shape
/// [`Self::execute`] requires (New Layer's fully), and it can run now. For a caller that does
/// something first only when the command will go through: Free Transform applies its box
/// before a new layer is made.
///
/// Complete for the commands in [`PRECHECKED`]: there it accepts what `execute` accepts, but
/// for the group nesting limit (a test holds the two together). For others it checks only what they share.
pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> {
let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if !(params.is_object() || params.is_null()) {
return Err(EngineError::BadParams { cmd: id.to_string(), msg: "params must be a JSON object".into() });
}
crate::param_check::check(spec, params)?;
crate::filters::validate_params(id, params)?;
if id == "layer.new.layer" {
crate::new_layer::parse(params)?;
}
// A command that takes `"layer"` (an id or a name, as it resolves it) fails on one the
// document doesn't have.
if spec.params.contains("\"layer\":id")
&& let Some(l) = commands::explicit_layer(self, params)?
&& self.active().is_none_or(|st| st.doc.layer(l).is_none())
{
return Err(EngineError::BadParams { cmd: id.to_string(), msg: format!("no layer {}", l.0) });
}
self.admit(id, spec, params).map(|_| ())
}
/// What `dispatch` checks once the params are valid and a floating selection has dropped:
/// the precondition (on a named layer for commands that take one), a job running on the
/// document, a hidden target. The run params (the Channels panel's target filled in) when it
/// may run.
pub(crate) fn admit(&mut self, id: &str, spec: &commands::CommandSpec, params: &Value) -> Result<Value> {
let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone()));
if let Err(why) = self.explicit_layer_enabled(spec, params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
return Err(EngineError::Disabled(id.to_string(), why));
}
if let Some(why) = self.job_conflict(id, spec.journal) {
return Err(EngineError::Disabled(id.to_string(), why));
}
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
if let Some(why) = hidden_target::refusal(self, id, params) {
return Err(EngineError::Other(why.into()));
}
Ok(run_params)
}
/// Is the command currently runnable? (drives menu enablement)
pub fn is_enabled(&self, id: &str) -> bool {
self.is_enabled_with(id, &Value::Null)
@@ -723,6 +779,9 @@ impl Session {
/// Apply an undoable edit to the active document.
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option<LayerId>) -> Result<R>) -> Result<R> {
// Every mutation path (linked-file refresh, transform, place) re-renders smart objects
// through this entry; the requesting session's authority must reach those renders.
let _authorization = crate::jobs::render_authorization_scope(self.authorize);
let restrict = self.color_restrict;
let sampler_map = self.sampler_map.take();
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
+41 -23
View File
@@ -24,9 +24,49 @@ pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> {
}
}
/// A New Layer call's params, checked.
pub(crate) struct NewLayer {
clip: bool,
mode: BlendMode,
opacity: f32,
label: LabelColor,
neutral: Option<[f32; 3]>,
below: bool,
name: Option<String>,
}
/// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the
/// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in.
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let NewLayer { clip, mode, opacity, label, neutral, below, name } = parse(p)?;
let id = s.edit("New Layer", |doc, active| {
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
let mut layer = Layer::raster(name, doc.pixel_format());
layer.blend = mode;
layer.opacity = opacity;
layer.label = label;
if let Some(rgb) = neutral {
let bounds = doc.bounds();
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
surface.fill_rect(bounds, &px);
}
// Nothing may sit under the Background: below it the layer lands above it instead.
let id = match *active {
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
_ => doc.insert_above(*active, layer),
};
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
*active = Some(id);
Ok(id)
})?;
crate::layer_multi_cmds::note_inert_insert(s, id);
Ok(json!({ "layer": id.0 }))
}
/// Check a New Layer call's params without running it (Free Transform applies its box before a
/// new layer only when the new layer can be made).
pub(crate) fn parse(p: &Value) -> Result<NewLayer> {
let flag = |k: &str| match p.get(k) {
None | Some(Value::Null) => Ok(false),
Some(v) => v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))),
@@ -62,29 +102,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()),
};
let id = s.edit("New Layer", |doc, active| {
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
let mut layer = Layer::raster(name, doc.pixel_format());
layer.blend = mode;
layer.opacity = opacity;
layer.label = label;
if let Some(rgb) = neutral {
let bounds = doc.bounds();
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
surface.fill_rect(bounds, &px);
}
// Nothing may sit under the Background: below it the layer lands above it instead.
let id = match *active {
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
_ => doc.insert_above(*active, layer),
};
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
*active = Some(id);
Ok(id)
})?;
crate::layer_multi_cmds::note_inert_insert(s, id);
Ok(json!({ "layer": id.0 }))
Ok(NewLayer { clip, mode, opacity, label, neutral, below, name })
}
#[cfg(test)]
+367 -31
View File
@@ -529,8 +529,19 @@ pub struct Performance {
/// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off:
/// they render at full resolution.
pub low_resolution_previews: bool,
/// Low-latency drawing (applies at next launch): frames reach the screen without waiting for
/// vsync, so a stroke follows the pen about 50 ms sooner (the swap chain's wait, #1343). wgpu
/// picks Immediate where the driver has it (most Vulkan and DX12 drivers on Windows and X11),
/// so the canvas usually tears while it changes; Mailbox (no tearing) only where Immediate is
/// missing. Frames are capped at 240 fps. Off (the default): vsync, smooth but late. Desktop
/// only.
pub low_latency_drawing: bool,
/// Linux display server (applies at next launch; see [`LinuxDisplayServer`]).
pub linux_display_server: LinuxDisplayServer,
/// GPU memory the canvas compositor may hold for layer pages and effect maps, in MB; 0 is
/// automatic (a graphics card's own memory isn't charged for the document's pixels, memory
/// shared with the system is). Layers that don't fit redraw the whole document on the CPU.
pub gpu_memory_mb: u32,
/// Memory budget of the layer-effect cache, in MB.
pub effect_cache_mb: u32,
pub legacy_compositing: bool,
@@ -560,7 +571,9 @@ impl Default for Performance {
rendering_mode: None,
gpu_backend: GpuBackend::Auto,
low_resolution_previews: true,
low_latency_drawing: false,
linux_display_server: LinuxDisplayServer::Auto,
gpu_memory_mb: 0,
effect_cache_mb: 768,
legacy_compositing: false,
}
@@ -1099,6 +1112,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> {
"interface.notificationDurationSeconds" => (1.0, 120.0),
"export.jpegQuality" | "export.webpQuality" => (1.0, 100.0),
"performance.memoryUsageMb" => (256.0, 1_048_576.0),
"performance.gpuMemoryMb" => (0.0, 1_048_576.0),
"performance.historyStates" => (1.0, 1000.0),
"performance.cacheLevels" => (1.0, 8.0),
"performance.cacheTileSize" => (256.0, 16384.0),
@@ -1495,9 +1509,65 @@ pub fn default_alternates(id: &str) -> impl Iterator<Item = &'static str> + '_ {
ALTERNATE_SHORTCUTS.iter().filter(move |(c, _)| *c == id).map(|(_, sc)| *sc)
}
/// Every bindable id with its default: commands, then the temporary tools.
/// Keys the system takes before the app sees them (switching apps, closing the window, force
/// quit): `edit.keyboardShortcuts` won't bind them, as Photoshop won't. `sc` is in our notation,
/// where `Cmd` is Ctrl off the Mac.
pub fn reserved_shortcut(sc: &str) -> bool {
let Some(n) = normalize_shortcut(sc) else { return false };
let reserved: &[&str] = if cfg!(target_os = "macos") {
&["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"]
} else if cfg!(target_os = "windows") {
&["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"]
} else {
// Linux and the BSDs: window switching and closing, and the virtual consoles.
&[
"Alt+Tab",
"Alt+Shift+Tab",
"Alt+F4",
"Cmd+Alt+Delete",
"Cmd+Alt+F1",
"Cmd+Alt+F2",
"Cmd+Alt+F3",
"Cmd+Alt+F4",
"Cmd+Alt+F5",
"Cmd+Alt+F6",
"Cmd+Alt+F7",
"Cmd+Alt+F8",
"Cmd+Alt+F9",
"Cmd+Alt+F10",
"Cmd+Alt+F11",
"Cmd+Alt+F12",
]
};
reserved.iter().filter_map(|r| normalize_shortcut(r)).any(|r| r == n)
}
/// What a bindable id is: a command (the engine's or the shell's), a tool, or a held tool.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BindKind {
Command,
Tool,
Hold,
}
/// Every bindable id: (id, label, menu path, default key, kind). The engine's commands, the
/// shell's ([`crate::shell_commands::SHELL_COMMANDS`]), the tools and the held temporary tools,
/// so Edit › Keyboard Shortcuts works the same with or without the app's UI.
pub fn bindable_items() -> impl Iterator<Item = (&'static str, &'static str, &'static [&'static str], Option<&'static str>, BindKind)> {
let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut, BindKind::Command));
let shell = crate::shell_commands::SHELL_COMMANDS.iter().map(|c| (c.0, c.1, c.2, c.3, BindKind::Command));
let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1, &["Tools"][..], t.2, BindKind::Tool));
let hold = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), BindKind::Hold));
engine.chain(shell).chain(tools).chain(hold)
}
/// Every bindable id with its default.
fn bindable() -> impl Iterator<Item = (&'static str, Option<&'static str>)> {
crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2))))
bindable_items().map(|i| (i.0, i.3))
}
fn is_tool(id: &str) -> bool {
crate::shell_commands::tool_default(id).is_some()
}
/// Shortcuts bound to more than one command: (shortcut, ids). `bindings` are (id, shortcut).
@@ -1866,18 +1936,45 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
let imported;
let mut import = None;
let p = match p.get("importKys") {
Some(xml) => {
let xml = xml.as_str().ok_or_else(|| bad(cmd, "`importKys` is the text of a .kys file"))?;
Some(v) => {
// The file's text, or {"path": …} to read it.
let read;
let xml = match v {
Value::String(s) => s.as_str(),
Value::Object(o) => {
let path =
o.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, "`importKys.path` is a .kys file"))?;
read = read_text_file(path, crate::kys::MAX_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
read.as_str()
}
_ => return Err(bad(cmd, "`importKys` is the text of a .kys file, or {\"path\": …}")),
};
let set = crate::kys::parse(xml).map_err(|e| bad(cmd, e))?;
let plan = crate::kys::plan(crate::kys::command_candidates(), &set);
let mut merged = serde_json::Map::new();
for (id, sc) in &plan.set {
// A key the system reserves is left out (reported as `reserved`), not a failed import.
let mut skipped: Vec<String> = Vec::new();
for (id, sc) in plan.set.iter().filter(|(id, sc)| {
let bad = reserved_shortcut(sc);
if bad {
skipped.push(format!("{id}: {sc}"));
}
!bad
}) {
let default = crate::command_specs().iter().find(|c| c.id == id).and_then(|c| c.shortcut).and_then(normalize_shortcut);
merged.insert(id.clone(), if default.as_deref() == Some(sc.as_str()) { Value::Null } else { json!(sc) });
}
// Tool keys: a tool's own letter restores it (and its Shift cycling).
let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1));
let (tool_keys, unknown_tools) = crate::kys::plan_tools(tools, &set);
for (id, key) in &tool_keys {
let own = crate::shell_commands::tool_default(id).flatten().is_some_and(|d| d.eq_ignore_ascii_case(key));
merged.insert(id.clone(), if own { Value::Null } else { json!(key) });
}
merged.extend(p.get("set").and_then(Value::as_object).cloned().unwrap_or_default());
let mut alternates = serde_json::Map::new();
for (id, keys) in &plan.alternate_keys {
let keys: &Vec<String> = &keys.iter().filter(|k| !reserved_shortcut(k)).cloned().collect();
let main = plan.set.get(id).map(String::as_str);
let default = Preferences::default().alternates(id, main);
alternates.insert(id.clone(), if *keys == default { Value::Null } else { json!(keys) });
@@ -1892,6 +1989,9 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
"alternates": plan.alternates,
"alternateKeys": plan.alternate_keys,
"toolKeys": set.tool_keys,
"tools": tool_keys,
"unknownTools": unknown_tools,
"reserved": skipped,
"windows": set.windows,
}));
let mut with_import = p.clone();
@@ -1914,6 +2014,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
if !set.is_empty() || !alts.is_empty() {
let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false);
changed = true;
// Reserved system keys can't be bound, by hand or from a file.
let reserved = |v: &Value| -> Option<String> {
let keys = match v {
Value::String(s) => vec![s.clone()],
other => alternate_keys(other).unwrap_or_default(),
};
keys.into_iter().find(|k| reserved_shortcut(k))
};
if let Some((id, k)) = set.iter().chain(alts.iter()).find_map(|(id, v)| reserved(v).map(|k| (id, k))) {
return Err(bad(cmd, format!("`{k}` (for `{id}`) is reserved by the system and can't be assigned")));
}
for (id, v) in set {
if !bindable().any(|(b, _)| b == id) && !allow_unknown {
return Err(bad(cmd, format!("unknown command `{id}`")));
@@ -1935,9 +2046,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
.chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k))))
.collect();
for (id, sc) in assigned {
// Shell commands (View › Zoom In) have alternates but no engine default.
let shell = ALTERNATE_SHORTCUTS.iter().filter(|(c, _)| crate::commands::find(c).is_none()).map(|(c, _)| (*c, None));
for (c, def) in bindable().chain(shell) {
for (c, def) in bindable() {
// Tools may share a key: they cycle with Shift, as Photoshop's groups do.
if is_tool(id) && is_tool(c) {
continue;
}
// Never strip a command that this same call is assigning:
// two entries for one key are a clash inside the call, which
// the returned conflicts list reports (#719). Stripping
@@ -1959,16 +2072,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
}
// Then the sets are saved, deleted or switched, from the bindings as they now stand.
changed |= crate::shortcut_sets::store(&mut next, p).map_err(|e| bad(cmd, e))?;
if changed {
s.edit_prefs(|p| *p = next);
}
let prefs = s.prefs();
// The reply and the exports read the bindings as they will be; they are committed only once
// the exports are written, so a bad export target changes nothing.
let prefs = &next;
let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase);
let temporary = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), true));
let list: Vec<Value> = crate::command_specs()
.iter()
.map(|c| (c.id, c.label, c.menu, c.shortcut, false))
.chain(temporary)
let list: Vec<Value> = bindable_items()
.filter(|c| filter.as_ref().is_none_or(|f| c.0.to_ascii_lowercase().contains(f) || c.1.to_ascii_lowercase().contains(f)))
.filter(|c| {
p.get("list").and_then(Value::as_bool).unwrap_or(false)
@@ -1976,15 +2084,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|| prefs.shortcuts.contains_key(c.0)
|| prefs.alternate_shortcuts.contains_key(c.0)
})
.map(|(id, label, menu, def, hold)| {
.map(|(id, label, menu, def, kind)| {
let main = prefs.shortcut(id, def);
let defaults: Vec<&str> = default_alternates(id).collect();
let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": main, "alternates": prefs.alternates(id, main)});
if !defaults.is_empty() {
v["defaultAlternates"] = json!(defaults);
}
if hold {
v["hold"] = json!(true);
match kind {
BindKind::Hold => v["hold"] = json!(true),
BindKind::Tool => v["tool"] = json!(true),
BindKind::Command => {}
}
v
})
@@ -1995,30 +2105,59 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
bindings.extend(main.map(|m| (id, m.to_string())));
bindings.extend(prefs.alternates(id, main).into_iter().map(|k| (id, k)));
}
let conflicts: Vec<Value> =
conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect();
// Tools sharing a key are a cycling group, not a conflict.
let conflicts: Vec<Value> = conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str())))
.into_iter()
.filter(|(_, ids)| !ids.iter().all(|id| is_tool(id)))
.map(|(sc, ids)| json!({"shortcut": sc, "commands": ids}))
.collect();
let mut out = json!({"overrides": prefs.shortcuts, "alternateOverrides": prefs.alternate_shortcuts, "commands": list, "conflicts": conflicts});
if let (Some(o), Value::Object(sets)) = (out.as_object_mut(), crate::shortcut_sets::describe(prefs)) {
o.extend(sets);
}
// The bindings as a `.kys` file, or Photoshop's Summarize page (the engine's commands; the
// app's dialog writes both with the shell's commands and tools added).
// The bindings as a `.kys` file, or Photoshop's Summarize page: every bindable item (the
// engine's commands, the shell's commands and the tools).
let set_name = if prefs.shortcut_set.is_empty() { crate::shortcut_sets::DEFAULTS_NAME } else { prefs.shortcut_set.as_str() };
if p.get("exportKys").and_then(Value::as_bool) == Some(true) {
// Both targets are resolved and their texts made before anything is written, and a path that
// can't be written is refused before either file is: a bad export changes nothing.
let kys_target = file_target(cmd, p.get("exportKys"))?;
let html_target = file_target(cmd, p.get("summarize"))?;
let mut files: Vec<(&str, String, String)> = Vec::new();
if let Some(target) = kys_target {
let entries: Vec<crate::kys::Entry> =
crate::shortcut_sets::bound(prefs).into_iter().map(|(id, label, _, keys)| crate::kys::Entry { id, label, tool: false, keys }).collect();
out["kys"] = json!(crate::kys::write(set_name, &entries));
crate::shortcut_sets::bound(prefs).into_iter().map(|b| crate::kys::Entry { id: b.id, label: b.label, tool: b.tool, keys: b.keys }).collect();
let text = crate::kys::write(set_name, &entries);
match target {
Some(path) => files.push(("kysPath", path, text)),
None => out["kys"] = json!(text),
}
}
if let Some(v) = p.get("summarize").filter(|v| !v.is_null() && *v != &json!(false)) {
if let Some(target) = html_target {
let v = p.get("summarize").cloned().unwrap_or(Value::Null);
let mac = v.get("mac").and_then(Value::as_bool).unwrap_or(cfg!(target_os = "macos"));
let mut sections: Vec<crate::shortcut_sets::Section> = Vec::new();
for (_, label, menu, keys) in crate::shortcut_sets::bound(prefs) {
for crate::shortcut_sets::Bound { label, menu, keys, .. } in crate::shortcut_sets::bound(prefs) {
match sections.iter_mut().find(|(m, _)| *m == menu) {
Some((_, rows)) => rows.push((label, keys)),
None => sections.push((menu, vec![(label, keys)])),
}
}
out["html"] = json!(crate::shortcut_sets::summarize(set_name, &sections, mac));
let html = crate::shortcut_sets::summarize(set_name, &sections, mac);
match target {
Some(path) => files.push(("htmlPath", path, html)),
None => out["html"] = json!(html),
}
}
for (_, path, _) in &files {
writable(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
}
let list: Vec<(&str, &str)> = files.iter().map(|(_, path, text)| (path.as_str(), text.as_str())).collect();
write_files_atomically(&list, &rename_path).map_err(|e| bad(cmd, e))?;
for (key, path, _) in &files {
out[*key] = json!(path);
}
if changed {
s.edit_prefs(|p| *p = next);
}
if let Some(import) = import {
out["import"] = import;
@@ -2026,6 +2165,203 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// An output parameter (`exportKys`, `summarize`): absent or false → `None`; true (or an object
/// without `path`) → `Some(None)`, the text comes back in the reply; `{"path": …}` →
/// `Some(Some(path))`, written there.
fn file_target(cmd: &str, v: Option<&Value>) -> Result<Option<Option<String>>> {
match v {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
Some(Value::Bool(true)) => Ok(Some(None)),
Some(Value::Object(o)) => match o.get("path") {
None | Some(Value::Null) => Ok(Some(None)),
Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(Some(s.clone()))),
Some(_) => Err(bad(cmd, "`path` must be a non-empty string")),
},
Some(_) => Err(bad(cmd, "expected true or {\"path\": …}")),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn read_text_file(path: &str, max: usize) -> std::result::Result<String, String> {
let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
if !meta.is_file() || meta.len() > max as u64 {
return Err(format!("not a file of at most {} KiB", max / 1024));
}
std::fs::read_to_string(path).map_err(|e| e.to_string())
}
#[cfg(target_arch = "wasm32")]
fn read_text_file(_: &str, _: usize) -> std::result::Result<String, String> {
Err("paths can't be read on the web; pass the file's text instead".into())
}
/// Whether `path` can be written: not a folder, in a folder that exists.
#[cfg(not(target_arch = "wasm32"))]
fn writable(path: &str) -> std::result::Result<(), String> {
let p = std::path::Path::new(path);
if p.is_dir() {
return Err("is a folder".into());
}
if std::fs::metadata(p).is_ok_and(|m| m.permissions().readonly()) {
return Err("is read-only".into());
}
match p.parent().filter(|d| !d.as_os_str().is_empty()) {
Some(d) if !d.is_dir() => Err("its folder doesn't exist".into()),
_ => Ok(()),
}
}
#[cfg(target_arch = "wasm32")]
fn writable(_: &str) -> std::result::Result<(), String> {
Err("paths can't be written on the web; omit `path` to get the text back".into())
}
#[cfg(not(target_arch = "wasm32"))]
fn rename_path(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
std::fs::rename(from, to)
}
#[cfg(target_arch = "wasm32")]
fn rename_path(_: &std::path::Path, _: &std::path::Path) -> std::io::Result<()> {
Err(std::io::Error::other("paths can't be written on the web"))
}
/// Where `path` points, for telling whether two targets are one file: its folder resolved, its
/// name compared without case where the file system ignores it.
#[cfg(not(target_arch = "wasm32"))]
fn path_key(path: &str) -> String {
let p = std::path::Path::new(path);
let dir = p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
let dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
let key = dir.join(p.file_name().unwrap_or_default()).to_string_lossy().into_owned();
if cfg!(any(target_os = "windows", target_os = "macos")) { key.to_lowercase() } else { key }
}
/// A new, empty file beside `path` with a name nothing else uses (`create_new`), for staging
/// (`kind` "tmp") or a backup ("bak").
#[cfg(not(target_arch = "wasm32"))]
fn sibling(path: &str, kind: &str) -> std::io::Result<(std::path::PathBuf, std::fs::File)> {
for n in 0..1000u32 {
let p = std::path::PathBuf::from(format!("{path}.{}-{n}.photocraft-{kind}", std::process::id()));
match std::fs::OpenOptions::new().write(true).create_new(true).open(&p) {
Ok(f) => return Ok((p, f)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::other("no free name beside it"))
}
/// Write `files` ((path, text)) all or nothing. Each text is staged in a new file beside its
/// target; once every one is written, each existing target is moved aside to a backup and the
/// staged file moved in. Any failure moves the backups back and removes what was staged, so the
/// user's files end as they began; on success the backups go. Two targets naming one file are
/// refused. `rename` is `std::fs::rename` (a test fails it on purpose).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_files_atomically(
files: &[(&str, &str)],
rename: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>,
) -> std::result::Result<(), String> {
use std::io::Write;
use std::path::{Path, PathBuf};
for (i, (a, _)) in files.iter().enumerate() {
if files.iter().skip(i + 1).any(|(b, _)| path_key(a) == path_key(b)) {
return Err(format!("{a}: two exports to the same file"));
}
}
let mut staged: Vec<PathBuf> = Vec::new();
let drop_all = |paths: &[PathBuf]| {
for p in paths {
let _ = std::fs::remove_file(p);
}
};
for (path, text) in files {
let (tmp, mut f) = match sibling(path, "tmp") {
Ok(v) => v,
Err(e) => {
drop_all(&staged);
return Err(format!("{path}: {e}"));
}
};
staged.push(tmp);
if let Err(e) = f.write_all(text.as_bytes()).and_then(|()| f.sync_all()) {
drop_all(&staged);
return Err(format!("{path}: {e}"));
}
}
// (target, its backup) of each file moved in so far.
let mut moved: Vec<(&Path, Option<PathBuf>)> = Vec::new();
// Put each backup back over its target (a target that had none is removed). What couldn't be
// put back is named, so the user can find their previous file.
let undo = |moved: &[(&Path, Option<PathBuf>)]| -> String {
let mut stranded = Vec::new();
for (target, backup) in moved.iter().rev() {
match backup {
Some(b) => {
if rename(b, target).is_err() {
stranded.push(format!("the previous {} is at {}", target.display(), b.display()));
}
}
None => {
let _ = std::fs::remove_file(target);
}
}
}
if stranded.is_empty() { String::new() } else { format!(" ({})", stranded.join("; ")) }
};
for ((path, _), tmp) in files.iter().zip(&staged) {
let target = Path::new(*path);
let backup = if target.exists() {
let moved_aside = sibling(path, "bak").and_then(|(b, f)| {
drop(f);
match rename(target, &b) {
Ok(()) => Ok(b),
Err(e) => {
// The placeholder the backup name reserved goes too.
let _ = std::fs::remove_file(&b);
Err(e)
}
}
});
match moved_aside {
Ok(b) => Some(b),
Err(e) => {
let stranded = undo(&moved);
drop_all(&staged);
return Err(format!("{path}: {e}{stranded}"));
}
}
} else {
None
};
if let Err(e) = rename(tmp, target) {
let mut stranded = undo(&moved);
if let Some(b) = &backup
&& rename(b, target).is_err()
{
stranded = format!("{stranded} (the previous {path} is at {})", b.display());
}
drop_all(&staged);
return Err(format!("{path}: {e}{stranded}"));
}
moved.push((target, backup));
}
for (_, backup) in moved {
if let Some(b) = backup {
let _ = std::fs::remove_file(b);
}
}
Ok(())
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn write_files_atomically(
files: &[(&str, &str)],
_: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>,
) -> std::result::Result<(), String> {
if files.is_empty() { Ok(()) } else { Err("paths can't be written on the web; omit `path` to get the text back".into()) }
}
/// Edit › Menus: hide/show items and give them colours.
fn menus(s: &mut Session, p: &Value) -> Result<Value> {
let ids = |k: &str| -> Vec<String> {
@@ -2151,7 +2487,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Keyboard Shortcuts…",
["Edit"],
Some("Cmd+Alt+Shift+K"),
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"exportKys":bool?,"summarize":bool|{"mac":bool}?}"##,
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"|{"path":".kys"}?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"exportKys":true|{"path":".kys"}?,"summarize":true|{"mac":bool,"path":".html"}?} (with a path the file is written and the reply has kysPath/htmlPath instead of kys/html)"##,
keyboard_shortcuts,
true
),
+88
View File
@@ -827,3 +827,91 @@ fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() {
let stored: Tools = serde_json::from_value(json!({"useShiftKeyForToolSwitch": false})).unwrap();
assert!(!stored.use_shift_key_for_tool_switch);
}
/// Shortcut exports are all or nothing (#5801): a rename that fails after the first file was
/// moved in puts the user's files back as they were and leaves nothing staged.
#[test]
fn exports_are_written_all_or_nothing() {
use super::write_files_atomically;
let dir = std::env::temp_dir().join(format!("photocraft-atomic-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (a, b) = (dir.join("a.kys"), dir.join("b.html"));
std::fs::write(&a, "old A").unwrap();
std::fs::write(&b, "old B").unwrap();
let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap());
let staged_moves = std::cell::Cell::new(0);
let failing = |from: &std::path::Path, to: &std::path::Path| {
if from.to_string_lossy().ends_with("photocraft-tmp") {
staged_moves.set(staged_moves.get() + 1);
if staged_moves.get() == 2 {
return Err(std::io::Error::other("disk gone"));
}
}
std::fs::rename(from, to)
};
assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing).is_err());
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into()));
let leftovers = || std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().contains(".photocraft-")).count();
assert_eq!(leftovers(), 0);
// A backup move that fails leaves no empty placeholder behind.
let backups = std::cell::Cell::new(0);
let failing_backup = |from: &std::path::Path, to: &std::path::Path| {
if to.to_string_lossy().ends_with("photocraft-bak") {
backups.set(backups.get() + 1);
if backups.get() == 2 {
return Err(std::io::Error::other("locked"));
}
}
std::fs::rename(from, to)
};
assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing_backup).is_err());
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into()));
assert_eq!(leftovers(), 0);
// The same target twice is refused before anything is written.
assert!(write_files_atomically(&[(pa, "x"), (pa, "y")], &|f, t| std::fs::rename(f, t)).is_err());
assert_eq!(std::fs::read_to_string(&a).unwrap(), "old A");
// A target named like a staging file is just a file.
let odd = dir.join("c.kys.photocraft-tmp");
std::fs::write(&odd, "old C").unwrap();
write_files_atomically(&[(pa, "new A"), (odd.to_str().unwrap(), "new C")], &|f, t| std::fs::rename(f, t)).unwrap();
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&odd).unwrap()), ("new A".into(), "new C".into()));
assert_eq!(leftovers(), 1, "only c.kys.photocraft-tmp itself");
let _ = std::fs::remove_dir_all(&dir);
}
/// A backup that can't be put back after a failed export is named in the error, and stays where
/// it is with the user's previous text.
#[test]
fn a_stranded_backup_is_named_in_the_error() {
use super::write_files_atomically;
let dir = std::env::temp_dir().join(format!("photocraft-stranded-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (a, b) = (dir.join("a.kys"), dir.join("b.html"));
std::fs::write(&a, "old A").unwrap();
std::fs::write(&b, "old B").unwrap();
let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap());
let staged = std::cell::Cell::new(0);
// The second staged move fails, and so does putting a.kys's backup back.
let rename = |from: &std::path::Path, to: &std::path::Path| {
let f = from.to_string_lossy();
if f.ends_with("photocraft-tmp") {
staged.set(staged.get() + 1);
if staged.get() == 2 {
return Err(std::io::Error::other("disk gone"));
}
}
if f.ends_with("photocraft-bak") && to == a.as_path() {
return Err(std::io::Error::other("locked"));
}
std::fs::rename(from, to)
};
let e = write_files_atomically(&[(pa, "new A"), (pb, "new B")], &rename).unwrap_err();
assert!(e.contains(&format!("the previous {} is at", a.display())), "{e}");
let backup: Vec<_> =
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with("photocraft-bak")).collect();
assert_eq!(backup.len(), 1, "{backup:?}");
assert!(e.contains(&backup[0].display().to_string()), "{e}");
assert_eq!(std::fs::read_to_string(&backup[0]).unwrap(), "old A");
assert_eq!(std::fs::read_to_string(&b).unwrap(), "old B");
let _ = std::fs::remove_dir_all(&dir);
}
+194
View File
@@ -0,0 +1,194 @@
//! The commands the app's shell carries out itself (view, panels, windows: they change no
//! document), and the tools, as data: what they're called and which keys they have by default.
//! The shell implements them (`photocraft_ui_egui::menus::UI_COMMANDS`, its `Tool`); the engine
//! knows them so a headless session sees every bindable command: Edit › Keyboard Shortcuts'
//! list, conflicts, `.kys` import and export all work without the UI (the UI is a layer on
//! top). The app's tests keep these tables equal to its own.
/// Shell commands: id, label, menu path, default shortcut.
pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("ui.symmetryTransform", "Transform Symmetry", &[], None),
// Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first.
("tool.reset", "Reset Tool", &[], None),
("tool.resetAll", "Reset All Tools", &[], None),
("window.panel.group", "Change Panel Group", &[], None),
("window.panel.layout", "Change Panel Layout", &[], None),
("window.panel.move", "Move Panel", &[], None),
("window.panel.float", "Float Panel", &[], None),
("window.panel.dock", "Dock Panel", &[], None),
("window.panel.activate", "Activate Panel", &[], None),
("window.panel.close", "Close Panel", &[], None),
("file.open", "Open…", &["File"], Some("Cmd+O")),
("file.save", "Save", &["File"], Some("Cmd+S")),
("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")),
("file.exit", "Exit", &["File"], Some("Cmd+Q")),
("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")),
("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")),
("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None),
("edit.transform.scale", "Scale", &["Edit", "Transform"], None),
("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None),
("edit.transform.skew", "Skew", &["Edit", "Transform"], None),
("edit.transform.distort", "Distort", &["Edit", "Transform"], None),
("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None),
("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")),
("view.rulers", "Rulers", &["View"], Some("Cmd+R")),
("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")),
("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")),
("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")),
("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")),
("view.extras", "Extras", &["View"], Some("Cmd+H")),
("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")),
("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")),
// Photoshop's interface brightness shortcuts: step through the four Pro themes.
("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")),
("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")),
("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")),
("type.editText", "Edit Type", &[], None),
("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")),
("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")),
("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")),
("view.actualPixels", "100%", &["View"], Some("Cmd+1")),
("view.rotateView", "Rotate View", &[], None),
("view.resetView", "Reset View", &["View"], None),
("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None),
("window.toggle.layers", "Layers", &["Window"], Some("F7")),
("window.toggle.history", "History", &["Window"], None),
("window.toggle.properties", "Properties", &["Window"], None),
("window.toggle.color", "Color", &["Window"], Some("F6")),
("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")),
("window.toggle.brushes", "Brushes", &["Window"], None),
("window.toggle.navigator", "Navigator", &["Window"], None),
("window.toggle.toolbar", "Tools", &["Window"], None),
// Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock.
("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")),
("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")),
("window.toggle.options", "Options", &["Window"], None),
// Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340).
("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")),
("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")),
("window.theme.toggle", "Next Appearance Mode", &["Window"], None),
("window.theme.pro", "Pro Theme", &["Window", "Theme"], None),
("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None),
("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None),
("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None),
("window.theme.studio", "Studio Theme", &["Window", "Theme"], None),
("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None),
("window.theme.classic", "Classic Theme", &["Window", "Theme"], None),
("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None),
("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None),
("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None),
("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None),
("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None),
("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None),
("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None),
("window.theme.system", "Sync with system", &["Window", "Theme"], None),
("edit.search", "Search…", &["Edit"], Some("Cmd+F")),
("help.discord", "Join the ArtCraft Discord…", &["Help"], None),
("help.website", "PhotoCraft Website", &["Help"], None),
("help.artcraftWebsite", "ArtCraft Website", &["Help"], None),
("help.github", "PhotoCraft on GitHub", &["Help"], None),
("help.reportIssue", "Report an Issue…", &["Help"], None),
("help.systemInfo", "System Info…", &["Help"], None),
("help.about", "About PhotoCraft", &["Help"], None),
];
/// The tools, in toolbar order: shortcut id (`tools.select.<tool>`), Photoshop's name for it
/// (ours too), and its default key. Tools that share a key cycle with Shift (Photoshop's
/// tool groups), so a shared default letter isn't a conflict.
pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
("tools.select.move", "Move Tool", Some("V")),
("tools.select.artboard", "Artboard Tool", Some("V")),
("tools.select.rectMarquee", "Rectangular Marquee Tool", Some("M")),
("tools.select.ellipseMarquee", "Elliptical Marquee Tool", Some("M")),
("tools.select.singleRowMarquee", "Single Row Marquee Tool", None),
("tools.select.singleColumnMarquee", "Single Column Marquee Tool", None),
("tools.select.lasso", "Lasso Tool", Some("L")),
("tools.select.polygonLasso", "Polygonal Lasso Tool", Some("L")),
("tools.select.magneticLasso", "Magnetic Lasso Tool", Some("L")),
("tools.select.magicWand", "Magic Wand Tool", Some("W")),
("tools.select.crop", "Crop Tool", Some("C")),
("tools.select.perspectiveCrop", "Perspective Crop Tool", Some("C")),
("tools.select.eyedropper", "Eyedropper Tool", Some("I")),
("tools.select.colorSampler", "Color Sampler Tool", Some("I")),
("tools.select.ruler", "Ruler Tool", Some("I")),
("tools.select.note", "Note Tool", Some("I")),
("tools.select.count", "Count Tool", Some("I")),
("tools.select.brush", "Brush Tool", Some("B")),
("tools.select.pencil", "Pencil Tool", Some("B")),
("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")),
("tools.select.colorReplacement", "Color Replacement Tool", Some("B")),
("tools.select.eraser", "Eraser Tool", Some("E")),
("tools.select.backgroundEraser", "Background Eraser Tool", Some("E")),
("tools.select.magicEraser", "Magic Eraser Tool", Some("E")),
("tools.select.gradient", "Gradient Tool", Some("G")),
("tools.select.paintBucket", "Paint Bucket Tool", Some("G")),
("tools.select.type", "Horizontal Type Tool", Some("T")),
("tools.select.verticalType", "Vertical Type Tool", Some("T")),
("tools.select.horizontalTypeMask", "Horizontal Type Mask Tool", Some("T")),
("tools.select.verticalTypeMask", "Vertical Type Mask Tool", Some("T")),
("tools.select.hand", "Hand Tool", Some("H")),
("tools.select.rotateView", "Rotate View Tool", Some("R")),
("tools.select.zoom", "Zoom Tool", Some("Z")),
("tools.select.remove", "Remove Tool", Some("J")),
("tools.select.spotHealing", "Spot Healing Brush Tool", Some("J")),
("tools.select.healing", "Healing Brush Tool", Some("J")),
("tools.select.patch", "Patch Tool", Some("J")),
("tools.select.contentAwareMove", "Content-Aware Move Tool", Some("J")),
("tools.select.redEye", "Red Eye Tool", Some("J")),
("tools.select.cloneStamp", "Clone Stamp Tool", Some("S")),
("tools.select.patternStamp", "Pattern Stamp Tool", Some("S")),
("tools.select.historyBrush", "History Brush Tool", Some("Y")),
("tools.select.blur", "Blur Tool", None),
("tools.select.sharpen", "Sharpen Tool", None),
("tools.select.smudge", "Smudge Tool", None),
("tools.select.dodge", "Dodge Tool", Some("O")),
("tools.select.burn", "Burn Tool", Some("O")),
("tools.select.sponge", "Sponge Tool", Some("O")),
("tools.select.quickSelection", "Quick Selection Tool", Some("W")),
("tools.select.objectSelection", "Object Selection Tool", Some("W")),
("tools.select.pen", "Pen Tool", Some("P")),
("tools.select.freeformPen", "Freeform Pen Tool", Some("P")),
("tools.select.curvaturePen", "Curvature Pen Tool", Some("P")),
("tools.select.addAnchorPoint", "Add Anchor Point Tool", Some("P")),
("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", Some("P")),
("tools.select.convertPoint", "Convert Point Tool", Some("P")),
("tools.select.pathSelection", "Path Selection Tool", Some("A")),
("tools.select.directSelection", "Direct Selection Tool", Some("A")),
("tools.select.rectangle", "Rectangle Tool", Some("U")),
("tools.select.ellipseShape", "Ellipse Tool", Some("U")),
("tools.select.triangle", "Triangle Tool", Some("U")),
("tools.select.polygon", "Polygon Tool", Some("U")),
("tools.select.line", "Line Tool", Some("U")),
("tools.select.customShape", "Custom Shape Tool", Some("U")),
("tools.select.slice", "Slice Tool", Some("C")),
("tools.select.sliceSelect", "Slice Select Tool", Some("C")),
("tools.select.frame", "Frame Tool", Some("K")),
];
/// The default key of tool shortcut `id`, if it's a tool.
pub fn tool_default(id: &str) -> Option<Option<&'static str>> {
TOOLS.iter().find(|t| t.0 == id).map(|t| t.2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_unique_and_keys_are_valid() {
let mut ids: Vec<&str> = SHELL_COMMANDS.iter().map(|c| c.0).chain(TOOLS.iter().map(|t| t.0)).collect();
let n = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), n, "duplicate ids");
for (id, _, _, sc) in SHELL_COMMANDS {
assert!(sc.is_none_or(|k| crate::prefs::normalize_shortcut(k).is_some()), "{id}: {sc:?}");
}
for (id, name, key) in TOOLS {
assert!(id.starts_with("tools.select.") && name.ends_with("Tool"), "{id} {name}");
assert!(key.is_none_or(|k| k.len() == 1), "{id}: {key:?}");
}
assert_eq!(tool_default("tools.select.move"), Some(Some("V")));
assert_eq!(tool_default("edit.copy"), None);
}
}
+26 -14
View File
@@ -234,20 +234,28 @@ pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String {
out
}
/// Every engine command (and temporary tool) with a key under the bindings of `p`: (id,
/// label, top-level menu, keys with the main one first). The app's dialog adds the commands
/// only the shell knows (View › Zoom In, the tools).
pub fn bound(p: &Preferences) -> Vec<(String, String, String, Vec<String>)> {
let temporary = crate::prefs::TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, "Tools", Some(t.2)));
crate::command_specs()
.iter()
.map(|c| (c.id, c.label, c.menu.first().copied().unwrap_or("Other"), c.shortcut))
.chain(temporary)
.filter_map(|(id, label, menu, def)| {
/// A command (or tool) with a key under some bindings.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Bound {
pub id: String,
pub label: String,
/// Its top-level menu ("Tools" for tools).
pub menu: String,
/// Main key first, then the further keys.
pub keys: Vec<String>,
pub tool: bool,
}
/// Every bindable command with a key under the bindings of `p`: the engine's and the shell's
/// commands, the tools and the temporary tools ([`crate::prefs::bindable_items`]).
pub fn bound(p: &Preferences) -> Vec<Bound> {
crate::prefs::bindable_items()
.filter_map(|(id, label, menu, def, kind)| {
let main = p.shortcut(id, def);
let mut keys: Vec<String> = main.and_then(normalize_shortcut).into_iter().collect();
keys.extend(p.alternates(id, main));
(!keys.is_empty()).then(|| (id.to_string(), label.to_string(), menu.to_string(), keys))
let menu = menu.first().copied().unwrap_or("Other").to_string();
(!keys.is_empty()).then(|| Bound { id: id.to_string(), label: label.to_string(), menu, keys, tool: kind == crate::prefs::BindKind::Tool })
})
.collect()
}
@@ -340,8 +348,12 @@ mod tests {
fn bound_lists_main_keys_then_alternates() {
let p = prefs();
let b = bound(&p);
let copy = b.iter().find(|(id, ..)| id == "edit.copy").expect("Copy has keys");
assert_eq!(copy.3, vec!["Cmd+C".to_string(), "F3".to_string()]);
assert!(b.iter().any(|(id, ..)| id == "tools.temporary.hand"));
let copy = b.iter().find(|x| x.id == "edit.copy").expect("Copy has keys");
assert_eq!(copy.keys, vec!["Cmd+C".to_string(), "F3".to_string()]);
assert!(b.iter().any(|x| x.id == "tools.temporary.hand"));
// The shell's commands and the tools too: a set saved headless is the whole set.
assert!(b.iter().any(|x| x.id == "view.zoomIn" && x.keys.len() >= 2), "Zoom In with its Cmd++");
let mv = b.iter().find(|x| x.id == "tools.select.move").expect("Move Tool");
assert!(mv.tool && mv.keys == ["V"] && mv.menu == "Tools");
}
}
+31 -13
View File
@@ -298,28 +298,35 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32)
out
}
/// Runs the smart filter stack on the placed (unfiltered) content.
pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Surface {
/// Runs the smart filter stack on the placed (unfiltered) content. A filter the session's
/// authority denies surfaces its error (the trusted compositor path has no gate installed, so
/// this reads ambiently only when the caller established one and it refuses).
pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Result<Surface> {
if !sm.filters_enabled || !sm.smart_filters.iter().any(|f| f.visible) {
return placed.clone();
return Ok(placed.clone());
}
let mut cur = placed.clone();
// Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters.
let extent = canvas.union(&placed.content_bounds());
for f in sm.smart_filters.iter().filter(|f| f.visible) {
cur = apply_smart_filter(f, &cur, canvas, extent);
cur = apply_smart_filter(f, &cur, canvas, extent)?;
}
match sm.filter_mask.as_ref().filter(|m| m.enabled) {
Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) {
Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m),
None => cur,
}
})
}
/// One visible smart filter over `cur`, blended with its blend mode and opacity.
fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Surface {
// Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone.
let Some(out) = crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent) else { return cur.clone() };
if f.blend == BlendMode::Normal && f.opacity >= 1.0 { out } else { blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0)) }
/// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the
/// requesting session's authority denies the filter's params: callers surface the error instead of
/// rendering (and caching) unfiltered pixels.
fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Result<Surface> {
let out = match crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent)? {
// Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone.
Some(out) => out,
None => cur.clone(),
};
if f.blend == BlendMode::Normal && f.opacity >= 1.0 { Ok(out) } else { Ok(blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0))) }
}
/// The source placed in document pixels, before any smart filter.
@@ -377,6 +384,11 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result<Option<Surface>> {
/// is cancelled. Gives the same pixels as [`apply_smart_filters`] on the placed source, but
/// starts from the deepest stage of the stack already rendered (see [`stack_cache`]).
fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Surface>> {
// Stored filters can come from a file or another layer. Check before looking up cached
// stages too: a trusted UI render may have populated this process-wide cache earlier.
for filter in sm.smart_filters.iter().filter(|f| sm.filters_enabled && f.visible) {
crate::jobs::authorize_render_command(&filter.command, &filter.params)?;
}
let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) };
let (fmt, canvas) = (doc.pixel_format(), doc.bounds());
let placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas);
@@ -410,7 +422,7 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Su
for (i, (f, key)) in filters.iter().zip(&keys).enumerate().skip(done) {
ctx.check()?;
ctx.progress(i as f32 / n, "");
cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent), extent: cur.extent };
cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent)?, extent: cur.extent };
stack_cache::put(*key, &cur);
}
ctx.progress(1.0, "");
@@ -522,7 +534,7 @@ pub(crate) fn add_smart_filter(doc: &mut Document, id: LayerId, sf: photocraft_d
}
let bounds = selection.map(Surface::content_bounds).filter(|b| !b.is_empty()).unwrap_or(canvas);
let cache = sm.cache.as_ref().ok_or_else(|| other("smart object has no pixels"))?;
let out = crate::filters::apply_filter_to_surface(&sf.command, &sf.params, cache, bounds, selection, canvas)
let out = crate::filters::apply_filter_to_surface(&sf.command, &sf.params, cache, bounds, selection, canvas)?
.ok_or_else(|| other(format!("unknown filter {}", sf.command)))?;
sm.cache = Some(out);
sm.smart_filters.push(sf);
@@ -1000,6 +1012,7 @@ fn set_filter_visible(s: &mut Session, p: &Value) -> Result<Value> {
fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.smartFilter.setParams";
let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters)"))?;
let authorize = s.authorize;
edit_filters(s, p, "Edit Smart Filter", |sm| {
let i = filter_index(CMD, p, sm)?;
let f = &mut sm.smart_filters[i];
@@ -1010,6 +1023,11 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
(Value::Object(old), Value::Object(n)) => old.extend(n),
(slot, n) => *slot = n,
}
// Authorize the underlying command with the complete merged params, including paths
// already stored in the document, before committing the edit or starting a worker.
if let Some(gate) = authorize {
gate(&f.command, &f.params)?;
}
Ok(())
})
}
@@ -17,7 +17,7 @@ fn uncached(s: &Session) -> Surface {
let doc = &s.active().unwrap().doc;
let sm = active_smart(s);
let (name, bytes) = source_bytes(&doc.metadata, &sm.source).unwrap();
apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds())
apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()).unwrap()
}
#[test]
@@ -0,0 +1,103 @@
//! #6613: each artboard must have its own output even when names collide on disk.
#![cfg(not(target_arch = "wasm32"))]
use std::collections::HashSet;
use std::path::Path;
use photocraft_engine::Session;
use serde_json::json;
struct Board {
id: u64,
width: u32,
color: [f32; 4],
}
fn fixture(depth: u32, names: &[&str]) -> (Session, Vec<Board>) {
let mut s = Session::new();
s.execute("file.new", json!({"name": "Board Export", "width": 96, "height": 32, "depth": depth})).unwrap();
let mut boards = Vec::new();
for (i, name) in names.iter().enumerate() {
let width = 3 + i as u32;
let color = if i % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] };
let result = s.execute("layer.new.artboard", json!({"name": name, "rect": [12 * i, 0, width, 2], "background": "custom", "color": color})).unwrap();
boards.push(Board { id: result["layer"].as_u64().unwrap(), width, color });
}
(s, boards)
}
fn check_export(depth: u32, names: &[&str], prefix: Option<&str>, format: &str, selected: Option<&[usize]>) {
let (mut s, boards) = fixture(depth, names);
let dir = tempfile::tempdir().unwrap();
let mut params = json!({"dir": dir.path(), "format": format});
if let Some(prefix) = prefix {
params["prefix"] = json!(prefix);
}
if let Some(selected) = selected {
params["artboards"] = json!(selected.iter().map(|i| boards[*i].id).collect::<Vec<_>>());
}
let expected: Vec<_> = boards.iter().enumerate().rev().filter(|(i, _)| selected.is_none_or(|ids| ids.contains(i))).map(|(_, b)| b).collect();
let before = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap();
let revision = s.active().unwrap().revision;
let history = s.active().unwrap().history.past_len();
let result = s.execute("file.export.artboardsToFiles", params).unwrap();
let files: Vec<_> = result["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(files.len(), expected.len());
// Also require uniqueness on filesystems that compare names without letter case.
assert_eq!(files.iter().map(|f| f.to_lowercase()).collect::<HashSet<_>>().len(), files.len(), "export paths collide: {files:?}");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), files.len(), "one physical output per returned path");
for (file, board) in files.iter().zip(expected) {
assert_eq!(Path::new(file).parent(), Some(dir.path()));
let bytes = std::fs::read(file).unwrap();
let doc = photocraft_io::import(file, &bytes).unwrap().document;
assert_eq!((doc.size.width, doc.size.height), (board.width, 2), "wrong artboard in {file}");
let px = photocraft_compose::flatten(&doc).px[0];
for (actual, expected) in px.iter().zip(board.color) {
assert!((actual - expected).abs() < 1e-3, "wrong pixels in {file}: {px:?}");
}
}
let after = s.active().unwrap();
assert_eq!(photocraft_format::save_to_bytes(&after.doc, &Default::default()).unwrap(), before);
assert_eq!(after.revision, revision, "export must not edit the source document");
assert_eq!(after.history.past_len(), history);
}
#[test]
fn duplicate_names_preserve_each_board_at_every_depth_and_prefix() {
for depth in [8, 16, 32] {
for prefix in [None, Some(""), Some("Launch/Run")] {
check_export(depth, &["Artboard 1", "Artboard 1"], prefix, "png", None);
}
}
}
#[test]
fn sanitized_and_empty_names_preserve_each_tiff() {
check_export(16, &["Hero/1", "Hero:1", "Hero?1", ".", " ", "layer"], Some(""), "tiff", None);
}
#[test]
fn case_only_and_index_like_names_remain_distinct() {
check_export(8, &["Logo", "logo", "Logo_0001", "0000_Logo", "Лого", "лого"], Some("site"), "png", None);
}
#[test]
fn selected_duplicate_names_keep_their_psd_contents_in_stack_order() {
check_export(16, &["Same", "Skip", "Same"], Some("chosen"), "psd", Some(&[0, 2]));
}
#[test]
fn distinct_names_keep_their_corresponding_contents() {
check_export(8, &["Red", "Blue"], None, "png", None);
}
#[test]
fn invalid_export_params_fail_before_creating_files() {
let (mut s, _) = fixture(8, &["One", "Two"]);
let dir = tempfile::tempdir().unwrap();
for params in [json!({"dir": 3}), json!({"dir": dir.path(), "artboards": []}), json!({"dir": dir.path(), "artboards": [u64::MAX]})] {
assert!(s.execute("file.export.artboardsToFiles", params).is_err());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
assert!(Session::new().execute("file.export.artboardsToFiles", json!({"dir": dir.path()})).is_err());
}
+78
View File
@@ -0,0 +1,78 @@
//! `Session::precheck` accepts what `execute` accepts for the commands it covers (all but the
//! group nesting limit, which only the edit checks)
//! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859).
use photocraft_engine::{PRECHECKED, Session};
use serde_json::{Value, json};
/// Documents in different states: a Background only, a pixel layer active, a group active, a
/// selection, no document.
fn states() -> Vec<(&'static str, Session)> {
let mut out = Vec::new();
out.push(("no document", Session::new()));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
out.push(("background only", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
out.push(("pixel layer", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
s.execute("layer.new.group", json!({})).unwrap();
out.push(("group, no background", s));
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
out.push(("selection", s));
out
}
fn params() -> Vec<Value> {
vec![
Value::Null,
json!({}),
json!([1]),
json!("x"),
json!({"mode": "Bogus"}),
json!({"mode": "Multiply"}),
json!({"fillNeutral": true}),
json!({"mode": "Overlay", "fillNeutral": true}),
json!({"position": "x"}),
json!({"position": "below"}),
json!({"opacity": "half"}),
json!({"name": 5}),
json!({"name": "ok"}),
json!({"color": "chartreuse"}),
json!({"layer": 999_999}),
json!({"layer": 1}),
json!({"layer": 2}),
json!({"layer": null}),
json!({"layer": "Layer 1"}),
json!({"layer": "no such layer"}),
json!({"layer": true}),
json!({"ifPresent": true}),
]
}
#[test]
fn precheck_agrees_with_execute() {
let mut disagreements = Vec::new();
for id in PRECHECKED {
for p in params() {
for (state, _) in states() {
// A fresh copy of the state for each call: execute changes it.
let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap();
let pre = s.precheck(id, &p).is_ok();
let ran = s.execute(id, p.clone()).is_ok();
if pre != ran {
disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}"));
}
}
}
}
assert!(disagreements.is_empty(), "{disagreements:#?}");
}
+146
View File
@@ -0,0 +1,146 @@
//! Edit › Keyboard Shortcuts without the UI: the shell's commands and the tools are bindable,
//! listed, imported and exported; tools share keys without conflicts; system keys are refused;
//! sets read and write files by path.
use photocraft_engine::Session;
use serde_json::{Value, json};
fn ks(s: &mut Session, p: Value) -> Value {
s.execute("edit.keyboardShortcuts", p).expect("edit.keyboardShortcuts")
}
fn listed<'a>(r: &'a Value, id: &str) -> &'a Value {
r["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("{id} listed"))
}
#[test]
fn shell_commands_and_tools_are_bindable_without_the_ui() {
let mut s = Session::new();
let r = ks(&mut s, json!({"list": true}));
assert_eq!(listed(&r, "view.zoomIn")["alternates"], json!(["Cmd++"]), "the shell's View › Zoom In");
let mv = listed(&r, "tools.select.move");
assert_eq!((mv["tool"].clone(), mv["shortcut"].clone(), mv["menu"].clone()), (json!(true), json!("V"), json!(["Tools"])));
assert!(
r["conflicts"].as_array().unwrap().iter().all(|c| c["commands"]
.as_array()
.unwrap()
.iter()
.any(|id| !id.as_str().unwrap().starts_with("tools.select."))),
"tools sharing a letter cycle, they don't conflict: {}",
r["conflicts"]
);
// No allowUnknown needed any more.
let r = ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "view.zoomIn": "Cmd+Shift+Z"}}));
assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["view.zoomIn"].clone()), (json!("F6"), json!("Cmd+Shift+Z")));
}
#[test]
fn tools_may_share_a_key_but_a_command_takes_it() {
let mut s = Session::new();
// Pencil joins the V group: Move and Artboard keep V.
let r = ks(&mut s, json!({"set": {"tools.select.pencil": "V"}}));
assert!(r["overrides"].get("tools.select.move").is_none(), "{}", r["overrides"]);
// A command taking V removes it from every tool that held it.
let r = ks(&mut s, json!({"set": {"edit.copy": "V"}}));
for t in ["tools.select.move", "tools.select.artboard", "tools.select.pencil"] {
assert_eq!(r["overrides"][t], json!(""), "{t}: {}", r["overrides"]);
}
}
#[test]
fn system_keys_are_refused_and_dropped_from_imports() {
let mut s = Session::new();
let key = if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" };
let before = s.prefs().clone();
assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": key}})).is_err());
assert!(s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9", key]}})).is_err());
assert_eq!(s.prefs(), &before);
let set = format!(
"<photoshop-keyboard-shortcuts><command name=\"Copy\"><shortcut>F9</shortcut><shortcut>{key}</shortcut></command><command name=\"Cut\"><shortcut>{key}</shortcut></command><tool name=\"Pencil Tool\">N</tool><tool name=\"Move Tool\">V</tool></photoshop-keyboard-shortcuts>"
);
let r = ks(&mut s, json!({"importKys": set}));
assert_eq!(r["overrides"]["edit.copy"], json!("F9"));
assert!(r["overrides"].get("edit.cut").is_none(), "the reserved key is left out: {}", r["overrides"]);
assert_eq!(r["import"]["reserved"].as_array().map(Vec::len), Some(1));
assert_eq!(r["overrides"]["tools.select.pencil"], json!("N"), "tool keys import headless");
assert!(r["overrides"].get("tools.select.move").is_none(), "Move's own V keeps its cycling");
}
#[test]
fn sets_read_and_write_files_and_export_everything() {
let dir = std::env::temp_dir().join(format!("photocraft-shortcuts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let kys = dir.join("mine.kys");
let html = dir.join("mine.html");
let mut s = Session::new();
ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "edit.copy": "F9"}, "saveSet": "Mine"}));
let r = ks(&mut s, json!({"exportKys": {"path": kys.to_str().unwrap()}, "summarize": {"path": html.to_str().unwrap(), "mac": false}}));
assert!(r.get("kys").is_none() && r["kysPath"].is_string() && r["htmlPath"].is_string(), "{r}");
let text = std::fs::read_to_string(&kys).unwrap();
assert!(text.contains("pc-id=\"tools.select.pencil\">F6</tool>") && text.contains("pc-id=\"view.zoomIn\""), "the whole set: {text}");
assert!(std::fs::read_to_string(&html).unwrap().contains("<h2>Tools</h2>"));
// Back to the defaults, then the file restores the set, tools included.
ks(&mut s, json!({"useSet": ""}));
let r = ks(&mut s, json!({"reset": true, "importKys": {"path": kys.to_str().unwrap()}}));
assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["edit.copy"].clone()), (json!("F6"), json!("F9")));
// Bad paths and shapes are errors, not panics.
for p in [
json!({"importKys": {"path": dir.join("missing.kys").to_str().unwrap()}}),
json!({"importKys": {"path": ""}}),
json!({"importKys": 5}),
json!({"exportKys": "yes"}),
json!({"exportKys": {"path": 3}}),
json!({"summarize": {"path": ""}}),
] {
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
}
// A change paired with an export that fails (a folder that doesn't exist, a bad summarize
// value) changes nothing.
let before = ks(&mut s, json!({}))["overrides"].clone();
for p in [
json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("no").join("such").join("x.kys").to_str().unwrap()}}),
json!({"set": {"edit.copy": "F10"}, "summarize": 7}),
json!({"set": {"edit.copy": "F10"}, "saveSet": "Other", "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}),
// A good .kys path with a bad summarize: neither file is written.
json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}),
json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": 7}),
json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.to_str().unwrap()}}),
] {
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
let r = ks(&mut s, json!({}));
assert_eq!(r["overrides"], before, "{p}");
assert!(r["sets"].get("Other").is_none(), "{p}: {r}");
assert!(!dir.join("both.kys").exists(), "{p}: no file written");
}
// A second file that passes the checks but can't be written (a NUL in its name): the user's
// existing first file is left exactly as it was, and no staged file stays behind.
let mine = dir.join("existing.kys");
std::fs::write(&mine, "the user's own file").unwrap();
let html = format!("{}/a\u{0}.html", dir.to_str().unwrap());
let p = json!({"exportKys": {"path": mine.to_str().unwrap()}, "summarize": {"path": html}});
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
assert_eq!(std::fs::read_to_string(&mine).unwrap(), "the user's own file");
let left: Vec<_> =
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).filter(|n| n.to_string_lossy().contains("photocraft-tmp")).collect();
assert!(left.is_empty(), "{left:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_read_only_target_is_refused_before_anything_is_written() {
let dir = std::env::temp_dir().join(format!("photocraft-readonly-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let ro = dir.join("locked.kys");
std::fs::write(&ro, "locked").unwrap();
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&ro, perms.clone()).unwrap();
let mut s = Session::new();
let r = s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": ro.to_str().unwrap()}}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("read-only")), "{r:?}");
assert_eq!(std::fs::read_to_string(&ro).unwrap(), "locked");
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
let _ = std::fs::set_permissions(&ro, perms);
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Geometry primitives shared by every Photocraft crate.
//! Geometry primitives shared by every PhotoCraft crate.
//!
//! Document space is integer pixels with the origin at the canvas top-left.
//! Layers may extend beyond the canvas (negative coordinates are valid).
+1 -1
View File
@@ -1,4 +1,4 @@
// Photocraft GPU compositor kernels.
// PhotoCraft GPU compositor kernels.
//
// Every pass renders one chunk of the canvas into a straight-alpha RGBA32F accumulator, reading
// its inputs with `textureLoad` at the same pixel. Effect-map kernels (`fs_m*`) render
+5
View File
@@ -794,6 +794,11 @@ impl Compositor {
total
}
/// The GPU memory resident layer pages may hold (bytes; see [`Compositor::set_memory_budget`]).
pub fn resident_budget(&self) -> u64 {
self.resident_budget
}
/// Whether a render of `region` keeps every layer page it needs within the budget; when it
/// doesn't, each render re-uploads what was evicted.
pub fn fits_budget(&self, doc: &Document, region: Rect) -> bool {
+5 -5
View File
@@ -181,7 +181,8 @@ fn layer_surface(img: &Image, max: &mut (u32, u32)) -> Result<(Surface, ColorMod
}
/// Imports the chosen entries as the layers of one document: parts whole, groups from their
/// own channels, F16 kept losslessly as F32, the linear-light EXR values tagged linear sRGB.
/// own channels, F16 kept losslessly as F32, the linear-light EXR values tagged linear sRGB
/// or linear gray.
/// `precomp` adds the additive sum of the file's AOV groups as a top layer and fails,
/// naming the missing AOVs, when the light-path set is incomplete.
pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result<ImportResult, IoError> {
@@ -270,10 +271,9 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result<ImportResult,
let mode = if gray_only { ColorMode::Grayscale } else { ColorMode::Rgb };
let mut doc = Document::new(name, Size::new(max.0, max.1), mode, DocSample::F32);
doc.layers = layers;
if mode == ColorMode::Rgb {
// OpenEXR holds linear, scene-referred values: tag them linear sRGB, as File › Open does.
doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes());
}
// OpenEXR holds linear, scene-referred values (Y is linear luminance): use the profile
// matching the selected parts' mode, as File › Open does.
doc.icc_profile = Some(if gray_only { photocraft_cms::builtin::linear_gray().to_bytes() } else { Builtin::LinearSrgb.profile().to_bytes() });
Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false })
}
+4 -3
View File
@@ -559,9 +559,10 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti
} else if let Some(linear) = convert_gray(&img, photocraft_cms::builtin::linear_gray(), CSample::F32)? {
img = linear;
}
} else if !format.caps().icc {
// Untagged files read back as sRGB/sGray: convert to it (as Quick Export does) rather than
// write values that only mean something under the dropped profile.
} else if !format.caps().icc || (img.layout().is_gray() && !format.caps().layouts.iter().any(|l| l.is_gray())) {
// Untagged files read back as sRGB/sGray. Even an ICC-capable format like WebP loses a
// gray profile when the codec expands gray to RGB. Convert first (as Quick Export does)
// rather than write values that only mean something under the dropped profile.
if let Some(srgb) = convert_rgb(&img, Builtin::Srgb.profile(), Intent::Perceptual, true, img.sample_type())? {
img = srgb;
warnings.push(format!("colours converted to sRGB; {format:?} can't embed the document's colour profile"));
+5 -1
View File
@@ -275,7 +275,11 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m
}
if is_psd(bytes) {
// A merged image the import reads is decoded with the parse: a ZIP one is inflated once.
let (file, merged) = PsdFile::from_bytes_and_merged(bytes, |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f))?;
let (file, merged) = PsdFile::from_bytes_and_merged_recovering(
bytes,
|f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f),
psd_import::can_recover_layers,
)?;
// Nesting past the document model's cap could never be saved; reject the file with the
// actionable limit.
psd_import::check_group_depth(&file)?;
+32 -5
View File
@@ -258,9 +258,23 @@ fn props(node: roxmltree::Node<'_, '_>, layer: &mut Layer, index: usize, warning
Ok(())
}
/// OpenRaster 0.0.6 defaults to isolated groups and makes `auto` explicitly pass-through.
/// Older files used inference from the group's blend mode; keep that behavior when the
/// version is absent or unrecognized, too. Compare the numeric core, not version strings.
fn uses_explicit_group_isolation(version: Option<&str>) -> bool {
let Some(core) = version.and_then(|v| v.trim().split(['-', '+']).next()) else { return false };
let mut parts = core.split('.');
let number = |part: Option<&str>| part.and_then(|s| s.parse::<u32>().ok());
match (number(parts.next()), number(parts.next()), number(parts.next()), parts.next()) {
(Some(major), Some(minor), Some(patch), None) => [major, minor, patch] >= [0, 0, 6],
_ => false,
}
}
struct Reader<'a, 'c> {
zip: ZipReader<'a>,
ctl: &'c Interrupt<'c>,
explicit_group_isolation: bool,
nodes: usize,
budget: usize,
warnings: Warnings,
@@ -325,10 +339,15 @@ impl Reader<'_, '_> {
"stack" => {
let mut layer = Layer::group("", Vec::new());
props(child, &mut layer, index, &mut self.warnings)?;
// `auto` (the default) isolates only when something needs it: an
// OpenRaster group without its own blend mode is a pass-through group.
let isolate = child.attribute("isolation").map(str::trim) == Some("isolate");
if !isolate && layer.blend == BlendMode::Normal {
let isolation = child.attribute("isolation").map(str::trim);
let pass_through = if self.explicit_group_isolation {
// Since 0.0.6 only explicit `auto` passes through, ignoring the
// group's composite-op. An omitted isolation means `isolate`.
isolation == Some("auto")
} else {
isolation != Some("isolate") && layer.blend == BlendMode::Normal
};
if pass_through {
layer.blend = BlendMode::PassThrough;
}
let children = self.stack(child, depth + 1)?;
@@ -415,7 +434,15 @@ pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result<Im
return Err(invalid("the canvas exceeds the 1 GiB pixel limit"));
}
let root = image.children().find(|n| n.has_tag_name("stack")).ok_or_else(|| invalid("stack.xml has no root <stack>"))?;
let mut reader = Reader { zip, ctl, nodes: 0, budget: MAX_PIXEL_BYTES, warnings: Warnings::default(), images: Vec::new() };
let mut reader = Reader {
zip,
ctl,
explicit_group_isolation: uses_explicit_group_isolation(image.attribute("version")),
nodes: 0,
budget: MAX_PIXEL_BYTES,
warnings: Warnings::default(),
images: Vec::new(),
};
let nodes = reader.stack(root, 1)?;
ctl.progress(0.8);
let depth = document_depth(&reader.images);
+78
View File
@@ -205,6 +205,84 @@ fn gray16_png(w: u32, h: u32) -> Vec<u8> {
codecs::encode(&Image::from_raw(w, h, ChannelLayout::Gray, CSample::U16, data).unwrap(), Format::Png, &Default::default()).unwrap()
}
/// A green Multiply layer over a red backdrop distinguishes isolation (green) from
/// pass-through (black), even though both groups use Normal compositing by default.
fn group_isolation_document(version: Option<&str>, attributes: &str, depth: SampleType) -> Document {
let version = version.map(|v| format!("version=\"{v}\"")).unwrap_or_default();
let xml = format!(
r#"<image {version} w="1" h="1"><stack>
<stack name="Group" {attributes}>
<layer name="Multiply" src="green.png" composite-op="svg:multiply"/>
</stack>
<layer name="Backdrop" src="red.png"/>
</stack></image>"#
);
let fmt = PixelFormat::new(ColorMode::Rgb, depth, true);
let png = |rgba| {
let image = Image::from_raw(1, 1, ChannelLayout::Rgba, csample(depth), px(fmt, rgba)).unwrap();
codecs::encode(&image, Format::Png, &Default::default()).unwrap()
};
let bytes = archive(&xml, &[("green.png", png([0.0, 1.0, 0.0, 1.0])), ("red.png", png([1.0, 0.0, 0.0, 1.0]))]);
let result = crate::import("isolation.ora", &bytes).unwrap();
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
assert_eq!(result.document.depth, depth);
result.document
}
#[test]
fn omitted_group_isolation_defaults_to_isolated_since_version_0_0_6() {
for depth in [SampleType::U8, SampleType::U16] {
for version in ["0.0.6", " 0.0.6 ", "0.0.6+app.1", "0.0.6-rc.1", "0.0.10", "0.1.0", "1.0.0"] {
let d = group_isolation_document(Some(version), "", depth);
assert_eq!(d.layers[1].blend, BlendMode::Normal, "{version}, {depth:?}");
assert_eq!(photocraft_compose::flatten(&d).px, [[0.0, 1.0, 0.0, 1.0]], "{version}, {depth:?}");
}
}
}
#[test]
fn explicit_group_isolation_preserves_isolate_and_auto() {
for depth in [SampleType::U8, SampleType::U16] {
for version in ["0.0.3", "0.0.5", "0.0.6"] {
for (attributes, blend, expected) in [
("isolation=' isolate '", BlendMode::Normal, [0.0, 1.0, 0.0, 1.0]),
("isolation=' auto '", BlendMode::PassThrough, [0.0, 0.0, 0.0, 1.0]),
("isolation='isolate' composite-op='svg:multiply'", BlendMode::Multiply, [0.0, 0.0, 0.0, 1.0]),
] {
let d = group_isolation_document(Some(version), attributes, depth);
assert_eq!(d.layers[1].blend, blend, "{version}, {attributes}, {depth:?}");
assert_eq!(photocraft_compose::flatten(&d).px, [expected], "{version}, {attributes}, {depth:?}");
}
}
}
}
#[test]
fn auto_group_ignores_its_composite_op_since_version_0_0_6() {
for depth in [SampleType::U8, SampleType::U16] {
let d = group_isolation_document(Some("0.0.6"), "isolation='auto' composite-op='svg:screen'", depth);
assert_eq!(d.layers[1].blend, BlendMode::PassThrough);
assert_eq!(photocraft_compose::flatten(&d).px, [[0.0, 0.0, 0.0, 1.0]], "{depth:?}");
}
}
#[test]
fn legacy_group_isolation_keeps_inference_from_blend_mode() {
for depth in [SampleType::U8, SampleType::U16] {
for version in [None, Some("0.0.3"), Some("0.0.5"), Some("unknown"), Some("0.0"), Some("0.0.6.0")] {
for (attributes, blend, expected) in [
("", BlendMode::PassThrough, [0.0, 0.0, 0.0, 1.0]),
("composite-op='svg:screen'", BlendMode::Screen, [1.0, 1.0, 0.0, 1.0]),
("isolation='auto' composite-op='svg:screen'", BlendMode::Screen, [1.0, 1.0, 0.0, 1.0]),
] {
let d = group_isolation_document(version, attributes, depth);
assert_eq!(d.layers[1].blend, blend, "{version:?}, {attributes}, {depth:?}");
assert_eq!(photocraft_compose::flatten(&d).px, [expected], "{version:?}, {attributes}, {depth:?}");
}
}
}
}
#[test]
fn reads_other_applications_stacks() {
// The shapes Krita, MyPaint and GIMP write: a root stack with attributes, `auto`
+12
View File
@@ -764,6 +764,12 @@ pub(crate) fn merged_needed(file: &PsdFile) -> bool {
!layered || file.layers().is_empty() || usize::from(h.channels) > mode.color_channels() + usize::from(file.merged_has_alpha())
}
/// A damaged merged preview need not prevent opening independently stored layers (#6152).
/// Other modes are imported flattened, so their merged image cannot be discarded.
pub(crate) fn can_recover_layers(file: &PsdFile) -> bool {
matches!(file.header.color_mode, PsdMode::Grayscale | PsdMode::Rgb | PsdMode::Cmyk | PsdMode::Lab) && file.header.depth != 1 && !file.layers().is_empty()
}
/// [`psd_to_document_with`] with the merged image already decoded (by
/// [`PsdFile::from_bytes_and_merged`] with [`merged_needed`]), so it is not decoded again.
pub(crate) fn psd_to_document_decoded(
@@ -901,9 +907,15 @@ pub(crate) fn psd_to_document_decoded(
ctl.progress(0.1);
if let Err(e) = &m {
cx.warn(format!("merged image could not be decoded: {e}"));
if can_recover_layers(file) {
cx.warn("extra alpha/spot channels in the merged image could not be recovered");
}
}
Some(m)
} else {
if let Some(Err(e)) = decoded {
cx.warn(format!("merged image could not be decoded; opened the available layers instead: {e}"));
}
None
};
+92 -3
View File
@@ -1,8 +1,8 @@
use photocraft_cms::{Builtin, Intent, Profile, Transform};
use photocraft_codecs::{ChannelLayout, SampleType as CodecSample, decode};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, LayerContent};
use photocraft_geom::Size;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, Layer, LayerContent, LayerMask};
use photocraft_geom::{Rect, Size};
use photocraft_io::{ExportOptions, document_to_image, export};
use photocraft_raster::Surface;
@@ -15,6 +15,32 @@ fn document(depth: SampleType, alpha: f32) -> Document {
doc
}
/// Three bands exercise opaque, translucent and empty pixels in the same composite.
fn mixed_region_document(depth: SampleType, opaque: bool) -> Document {
let mut doc = Document::new("CMYK regions", Size::new(48, 16), ColorMode::Cmyk, depth);
let mut bottom = Surface::new(doc.pixel_format());
bottom.fill_rect(Rect::new(0, 0, 16, 16), &[0.2, 0.6, 0.1, 0.15, 1.0]);
bottom.fill_rect(Rect::new(16, 0, 32, 16), &[0.1, 0.2, 0.7, 0.25, 0.5]);
if opaque {
bottom.fill_rect(doc.bounds(), &[0.2, 0.6, 0.1, 0.15, 1.0]);
}
doc.layers.push(Layer::new("Ink", LayerContent::Raster(bottom)));
let mut surface = Surface::new(doc.pixel_format());
surface.fill_rect(Rect::new(0, 0, 16, 16), &[0.7, 0.1, 0.3, 0.2, 1.0]);
let mut top = Layer::new("Masked ink", LayerContent::Raster(surface));
top.opacity = 0.5;
top.mask = Some(LayerMask {
surface: Surface::with_default(PixelFormat::new(ColorMode::Grayscale, depth, false), &[0.5]),
enabled: true,
linked: true,
density: 1.0,
feather: 0.0,
});
doc.layers.push(top);
doc.resolution_dpi = 300.0;
doc
}
fn tag(bytes: &[u8], id: u16) -> Option<u16> {
assert_eq!(&bytes[..2], b"II");
let start = usize::try_from(u32::from_le_bytes(bytes[4..8].try_into().unwrap())).unwrap();
@@ -71,6 +97,69 @@ fn tiff_keeps_true_zero_alpha_when_composited() {
}
}
#[test]
fn flat_and_layered_tiff_agree_for_mixed_cmyk_regions() {
let custom = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams {
description: "Mixed regions uncoated".into(),
tvi: [0.26, 0.26, 0.26, 0.3],
grid_a2b: 5,
grid_b2a: 9,
..Default::default()
});
let coated = Builtin::CoatedCmyk.profile().to_bytes();
for (name, profile) in [("untagged", None), ("coated", Some(coated.clone())), ("uncoated", Some(custom.to_bytes()))] {
for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] {
for opaque in [false, true] {
let context = format!("{name}, {depth:?}, opaque={opaque}");
let mut doc = mixed_region_document(depth, opaque);
doc.icc_profile = profile.clone();
let flat = export(&doc, "tiff", &ExportOptions::default()).unwrap();
let layered = export(&doc, "tiff", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap();
let image = decode(&flat.bytes).unwrap();
let reference = decode(&layered.bytes).unwrap();
assert!(!flat.warnings.iter().any(|warning| warning.contains("sRGB")), "{context}: {:?}", flat.warnings);
// Flat composites embed the fallback profile; the layered path keeps the source tag.
assert_eq!(image.icc.as_deref(), Some(profile.as_ref().unwrap_or(&coated).as_slice()), "{context}");
assert_eq!(reference.icc.as_deref(), profile.as_deref().map(Vec::as_slice), "{context}");
assert!(image.meta.photoshop_layers.is_none(), "{context}");
assert!(reference.meta.photoshop_layers.is_some(), "{context}");
for (decoded, bytes) in [(&image, &flat.bytes), (&reference, &layered.bytes)] {
assert_eq!(decoded.dimensions(), (48, 16), "{context}");
assert_eq!(decoded.layout(), if opaque { ChannelLayout::Cmyk } else { ChannelLayout::CmykA }, "{context}");
assert_eq!(decoded.sample_type(), sample, "{context}");
assert_eq!(decoded.meta.dpi, Some((300.0, 300.0)), "{context}");
assert_eq!(tag(bytes, 262), Some(5), "{context}");
assert_eq!(tag(bytes, 277), Some(if opaque { 4 } else { 5 }), "{context}");
assert_eq!(tag(bytes, 338), if opaque { None } else { Some(2) }, "{context}");
}
// The PSD-backed layered path inverts CMYK samples twice, so allow one stored level.
let tolerance = match depth {
SampleType::U8 => 1.0 / 255.0,
SampleType::U16 => 1.0 / 65535.0,
SampleType::F32 => 1e-6,
};
let actual = image.to_normalized();
let expected = reference.to_normalized();
assert_eq!(actual.len(), expected.len(), "{context}");
let difference = actual.iter().zip(&expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(difference <= tolerance + 1e-6, "{context}: maximum sample difference {difference}");
if !opaque {
for (i, px) in actual.as_chunks::<5>().0.iter().enumerate() {
match i % 48 {
0..16 => assert_eq!(px[4], 1.0, "{context}: opaque region"),
16..32 => assert!((px[4] - 0.5).abs() <= tolerance, "{context}: translucent region: {px:?}"),
_ => assert_eq!(px[4], 0.0, "{context}: empty region"),
}
}
}
let back = photocraft_io::import("regions.tiff", &flat.bytes).unwrap().document;
assert_eq!((back.mode, back.depth), (ColorMode::Cmyk, depth), "{context}");
assert_eq!(back.resolution_dpi, 300.0, "{context}");
}
}
}
}
#[test]
fn psd_keeps_cmyk_mode_depth_and_profile_when_composited() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
+1 -1
View File
@@ -132,7 +132,7 @@ thread_local! {
}
/// Files known to be invalid upstream: their parse error is expected.
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "malformed merged image data; recovery is tested in psd_damaged_merged.rs (#6152)")];
/// One corpus directory with its own floors.
struct Source {
+117
View File
@@ -0,0 +1,117 @@
//! Selecting an EXR part must interpret its linear samples like ordinary File > Open.
use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform};
use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CodecSample};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::Document;
use photocraft_io::{ExportOptions, ImportResult, export, exr_parts, import};
fn exr(layout: ChannelLayout, depth: CodecSample) -> Vec<u8> {
// Exactly representable in half precision, including negative and HDR luminance.
let samples: Vec<f32> = [-0.25, 0.25, 0.5, 2.0]
.into_iter()
.zip([1.0, 0.5, 0.25, 0.0])
.flat_map(|(v, a)| match layout {
ChannelLayout::Gray => vec![v],
ChannelLayout::GrayA => vec![v, a],
ChannelLayout::Rgb => vec![v, 0.125, 0.75],
ChannelLayout::Rgba => vec![v, 0.125, 0.75, a],
_ => panic!("not an EXR layout"),
})
.collect();
let image = Image::from_f32(4, 1, layout, &samples).unwrap().convert(layout, depth);
codecs::encode(&image, Format::OpenExr, &Default::default()).unwrap()
}
fn selected_part(bytes: &[u8]) -> ImportResult {
let entries = exr_parts::entries(bytes).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].kind, exr_parts::EntryKind::Part);
let selection = exr_parts::Selection { parts: vec![entries[0].name.clone()], groups: vec![], view: "both".into(), precomp: false };
exr_parts::import("selected", bytes, &selection).unwrap()
}
fn samples(doc: &Document) -> Vec<f32> {
assert_eq!(doc.layers.len(), 1);
doc.layers[0].surface().unwrap().read_region(doc.bounds())
}
#[test]
fn selected_gray_exr_part_keeps_linear_profile_and_float_samples() {
for depth in [CodecSample::F16, CodecSample::F32] {
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA] {
let bytes = exr(layout, depth);
let selected = selected_part(&bytes);
let ordinary = import("ordinary.exr", &bytes).unwrap();
let doc = &selected.document;
assert_eq!((doc.mode, doc.depth), (ColorMode::Grayscale, SampleType::F32));
assert_eq!(doc.size, ordinary.document.size);
assert_eq!(selected.warnings, ordinary.warnings);
assert_eq!(selected.warnings.len(), usize::from(depth == CodecSample::F16));
let expected = codecs::decode(&bytes).unwrap().convert(ChannelLayout::GrayA, CodecSample::F32).to_f32_samples().unwrap();
assert_eq!(samples(doc), expected, "{layout:?} {depth:?}: samples and alpha stay unchanged");
assert_eq!(samples(doc), samples(&ordinary.document));
assert_eq!(doc.icc_profile, Some(photocraft_cms::builtin::linear_gray().to_bytes()), "{layout:?} {depth:?}: linear luminance");
assert_eq!(doc.icc_profile, ordinary.document.icc_profile);
// The tagged samples convert to display gray, rather than being interpreted as sGray.
let profile = Profile::parse(doc.icc_profile.as_deref().unwrap()).unwrap();
assert_eq!(profile.color_space, ColorSpace::Gray);
let transform = Transform::new(&profile, Builtin::SGray.profile(), Intent::RelativeColorimetric, false).unwrap();
let mut display = samples(doc);
transform.apply(&mut display, 2);
for i in [1, 2] {
let expected_gray = photocraft_color::convert::linear_to_srgb(expected[i * 2]);
assert!((display[i * 2] - expected_gray).abs() < 1e-4, "{layout:?} {depth:?}: display gray");
assert_eq!(display[i * 2 + 1], expected[i * 2 + 1], "alpha is not gamma encoded");
}
}
}
}
#[test]
fn selected_gray_exr_part_exports_like_ordinary_import() {
for depth in [CodecSample::F16, CodecSample::F32] {
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA] {
let bytes = exr(layout, depth);
let selected = selected_part(&bytes).document;
let ordinary = import("ordinary.exr", &bytes).unwrap().document;
for ext in ["exr", "tga"] {
let output = export(&selected, ext, &ExportOptions::default()).unwrap();
let control = export(&ordinary, ext, &ExportOptions::default()).unwrap();
let output = codecs::decode(&output.bytes).unwrap();
let control = codecs::decode(&control.bytes).unwrap();
assert_eq!(output.layout(), control.layout());
assert_eq!(output.to_normalized(), control.to_normalized(), "{layout:?} {depth:?} {ext}: same colours as File > Open");
let output = output.convert(ChannelLayout::GrayA, CodecSample::F32).to_f32_samples().unwrap();
let original = samples(&selected);
if ext == "exr" {
assert_eq!(output, original, "linear EXR samples, including negative/HDR and alpha, survive re-export");
} else {
for i in [1, 2] {
let expected = photocraft_color::convert::linear_to_srgb(original[i * 2]);
assert!((output[i * 2] - expected).abs() <= 1.0 / 255.0, "TGA uses display-encoded gray");
assert!((output[i * 2 + 1] - original[i * 2 + 1]).abs() <= 1.0 / 255.0, "TGA preserves alpha");
}
}
}
}
}
}
#[test]
fn selected_rgb_exr_part_keeps_linear_srgb() {
for depth in [CodecSample::F16, CodecSample::F32] {
for layout in [ChannelLayout::Rgb, ChannelLayout::Rgba] {
let bytes = exr(layout, depth);
let doc = selected_part(&bytes).document;
let ordinary = import("ordinary.exr", &bytes).unwrap().document;
assert_eq!((doc.mode, doc.depth), (ColorMode::Rgb, SampleType::F32));
assert_eq!(doc.icc_profile, Some(Builtin::LinearSrgb.profile().to_bytes()));
assert_eq!(doc.icc_profile, ordinary.icc_profile);
let expected = codecs::decode(&bytes).unwrap().convert(ChannelLayout::Rgba, CodecSample::F32).to_f32_samples().unwrap();
assert_eq!(samples(&doc), expected, "{layout:?} {depth:?}: RGB and alpha stay unchanged");
assert_eq!(samples(&doc), samples(&ordinary));
}
}
}
+67
View File
@@ -610,6 +610,73 @@ fn linear_gray_export_preserves_alpha_and_embeddable_profiles() {
}
}
/// WebP can embed RGB profiles, but expanding gray to RGB drops a gray profile. Its untagged
/// RGB samples must use the sRGB transfer function, not the source gray encoding.
#[test]
fn linear_gray_webp_exports_use_display_encoding() {
use photocraft_codecs::{ChannelLayout, EncodeOptions};
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [1.0, 0.5] {
let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, alpha], &[0.5, alpha]]);
d.icc_profile = Some(photocraft_cms::builtin::linear_gray().to_bytes());
let stored = column_pixels(&d, 2);
for lossless in [true, false] {
let opts = ExportOptions { encode: EncodeOptions { webp_lossless: lossless, webp_quality: 100, ..Default::default() }, ..Default::default() };
let r = export(&d, "webp", &opts).unwrap();
let img = photocraft_codecs::decode(&r.bytes).unwrap();
let what = format!("{depth:?}, alpha {alpha}, lossless {lossless}");
assert_eq!(img.layout(), if alpha == 1.0 { ChannelLayout::Rgb } else { ChannelLayout::Rgba }, "{what}");
assert_eq!(img.icc, None, "{what}: a gray ICC cannot describe RGB samples");
let values = img.to_normalized();
let stride = img.layout().channels();
let tolerance = if lossless { 1.5 / 255.0 } else { 3.0 / 255.0 };
for (i, src) in stored.iter().enumerate() {
let offset = (8 * img.width() as usize + 16 * i + 8) * stride;
let expected = photocraft_color::convert::linear_to_srgb(src[0]);
for &value in &values[offset..offset + 3] {
assert!((value - expected).abs() <= tolerance, "{what}, pixel {i}: got {value}, expected {expected}");
}
if img.layout().has_alpha() {
assert!((values[offset + 3] - src[1]).abs() <= 1.0 / 255.0, "{what}: alpha changed");
}
}
assert!(r.warnings.iter().any(|w| w.contains("converted to sGray")), "{what}: {:?}", r.warnings);
}
}
}
}
#[test]
fn default_gray_webp_exports_keep_samples() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for icc in [None, Some(photocraft_cms::Builtin::SGray.profile().to_bytes())] {
let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, 0.5], &[0.5, 1.0]]);
d.icc_profile = icc;
let stored = column_pixels(&d, 2);
let r = export(&d, "webp", &ExportOptions::default()).unwrap();
let img = photocraft_codecs::decode(&r.bytes).unwrap();
assert_eq!(img.icc, None, "{depth:?}: a gray ICC cannot describe RGB samples");
let back = import("gray.webp", &r.bytes).unwrap().document;
let want: Vec<Vec<f32>> = stored.iter().map(|p| vec![p[0], p[0], p[0], p[1]]).collect();
assert_colors(&column_pixels(&back, 2), &want, 1.0 / 255.0, &format!("{depth:?}: sGray samples"));
assert!(!r.warnings.iter().any(|w| w.contains("converted to sGray")), "{depth:?}: {:?}", r.warnings);
}
}
}
#[test]
fn rgb_webp_exports_keep_embeddable_profiles() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = columns(ColorMode::Rgb, depth, &[&[0.25, 0.5, 0.75, 0.5]]);
d.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes());
let r = export(&d, "webp", &ExportOptions::default()).unwrap();
let back = import("rgb.webp", &r.bytes).unwrap().document;
assert_eq!(back.icc_profile, d.icc_profile, "{depth:?}: WebP keeps RGB ICC");
assert_colors(&column_pixels(&back, 1), &column_pixels(&d, 1), 1.0 / 255.0, &format!("{depth:?}: RGB samples"));
assert!(!r.warnings.iter().any(|w| w.contains("converted to sRGB")), "{depth:?}: {:?}", r.warnings);
}
}
/// #518: a JPEG cut off inside its image data opens with a warning, never silently.
#[test]
fn truncated_jpeg_imports_with_a_warning() {
+125
View File
@@ -0,0 +1,125 @@
//! #6152: intact layers can be recovered when only the merged preview is damaged.
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer};
use photocraft_geom::Size;
use photocraft_io::{ExportOptions, export, import};
use photocraft_psd::{Compression, GroupSpec, PsdBuilder, PsdFile};
fn layered(depth: SampleType, extension: &str) -> PsdFile {
layered_in_mode(ColorMode::Rgb, depth, extension)
}
fn layered_in_mode(mode: ColorMode, depth: SampleType, extension: &str) -> PsdFile {
let mut doc = Document::new("recovery", Size::new(3, 2), mode, depth);
let format = doc.pixel_format();
let mut layer = Layer::raster("red pixels", doc.pixel_format());
let mut pixel = vec![0.0; format.channels()];
photocraft_raster::from_rgba_into(&format, [1.0, 0.0, 0.0, 1.0], &mut pixel);
layer.surface_mut().unwrap().fill_rect(doc.bounds(), &pixel);
doc.layers.push(layer);
PsdFile::from_bytes(&export(&doc, extension, &ExportOptions::default()).unwrap().bytes).unwrap()
}
#[test]
fn truncated_merged_image_keeps_layer_pixels_at_every_depth_and_compression() {
use ColorMode::{Cmyk, Grayscale, Lab, Rgb};
use SampleType::{F32, U8, U16};
for extension in ["x.psd", "x.psb"] {
for (mode, depth) in
[(Rgb, U8), (Rgb, U16), (Rgb, F32), (Grayscale, U8), (Grayscale, U16), (Grayscale, F32), (Cmyk, U8), (Cmyk, U16), (Lab, U8), (Lab, U16)]
{
for compression in Compression::ALL {
let mut file = layered_in_mode(mode, depth, extension);
let intact = import(extension, &file.to_bytes().unwrap()).unwrap();
let expected = photocraft_compose::flatten(&intact.document).px;
let pixels = file.decode_merged().unwrap();
file.image_data = photocraft_psd::ImageData::encode(compression, &pixels, &file.header).unwrap();
file.image_data.data.truncate(file.image_data.data.len() / 2);
let bytes = file.to_bytes().unwrap();
assert!(PsdFile::from_bytes(&bytes).is_err(), "strict parsing stays strict");
let result = import(extension, &bytes).expect("recover the intact layers");
assert_eq!(result.document.layer_count(), 1);
assert_eq!(result.document.depth, depth);
assert!(result.warnings.iter().any(|w| w.contains("merged image")), "{:?}", result.warnings);
let rendered = photocraft_compose::flatten(&result.document);
assert_eq!(rendered.px, expected, "{extension} {mode:?} {depth:?} {compression:?}");
// Saving the recovered document replaces the bad preview with a valid one.
let saved = export(&result.document, extension, &ExportOptions::default()).unwrap();
assert!(PsdFile::from_bytes(&saved.bytes).unwrap().decode_merged().is_ok());
}
}
}
}
#[test]
fn corrupt_zip_merged_image_keeps_the_layers() {
let mut file = layered(SampleType::U8, "x.psd");
file.image_data = photocraft_psd::ImageData::encode(Compression::Zip, &file.decode_merged().unwrap(), &file.header).unwrap();
*file.image_data.data.last_mut().unwrap() ^= 0xff;
let result = import("x.psd", &file.to_bytes().unwrap()).unwrap();
assert_eq!(result.document.layer_count(), 1);
assert!(result.warnings.iter().any(|w| w.contains("merged image")));
}
#[test]
fn an_empty_group_survives_a_truncated_preview() {
// The reported psd-tools fixture has only the two records of an empty group.
let mut builder = PsdBuilder::new(100, 100);
builder.begin_group(GroupSpec::new("empty group"));
builder.end_group().unwrap();
let mut file = builder.build().unwrap();
file.image_data.data.truncate(file.image_data.data.len() / 2);
let result = import("empty.psd", &file.to_bytes().unwrap()).unwrap();
assert_eq!(result.document.layer_count(), 1);
assert_eq!(result.document.layers[0].name, "empty group");
assert!(result.warnings.iter().any(|w| w.contains("merged image")));
}
#[test]
fn a_flattened_file_with_no_recoverable_layers_still_fails() {
let mut file = layered(SampleType::U8, "x.psd");
file.layer_info = None;
file.image_data.data.truncate(file.image_data.data.len() / 2);
assert!(import("flat.psd", &file.to_bytes().unwrap()).is_err());
}
#[test]
fn modes_imported_flattened_do_not_recover_from_unusable_layer_records() {
for mode in [photocraft_psd::ColorMode::Indexed, photocraft_psd::ColorMode::Multichannel, photocraft_psd::ColorMode::Duotone] {
let mut file = layered(SampleType::U8, "x.psd");
file.header.color_mode = mode;
file.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: Vec::new() };
assert!(import("flat-mode.psd", &file.to_bytes().unwrap()).is_err(), "{mode:?}");
}
}
#[test]
fn lost_extra_channels_are_reported_while_layers_remain_usable() {
let mut file = layered(SampleType::U8, "x.psd");
file.header.channels = 5;
file.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: Vec::new() };
let result = import("extra-channel.psd", &file.to_bytes().unwrap()).unwrap();
assert_eq!(result.document.layer_count(), 1);
assert!(result.document.channels.is_empty());
assert!(result.warnings.iter().any(|w| w.contains("alpha/spot channels")), "{:?}", result.warnings);
}
#[test]
fn truncated_layer_sections_are_not_recovered() {
let file = layered(SampleType::U8, "x.psd");
let mut bytes = file.to_bytes().unwrap();
bytes.truncate(bytes.len() - file.image_data.data.len() - 3);
assert!(import("broken-layers.psd", &bytes).is_err());
}
#[cfg(feature = "corpus")]
#[test]
fn reported_group_divider_fixture_opens_with_its_group() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/blend-modes/group-divider-blend-mode.psd");
let bytes = std::fs::read(path).expect("run cargo xtask corpus --psd-tools");
let result = import("group-divider-blend-mode.psd", &bytes).expect("#6152 opens");
assert_eq!(result.document.layer_count(), 1);
assert!(matches!(result.document.layers[0].content, photocraft_doc::LayerContent::Group(_)));
assert!(result.warnings.iter().any(|w| w.contains("merged image")));
}
+1 -1
View File
@@ -169,7 +169,7 @@ fn corpus_photoshop_txt2_survives_an_unedited_save() {
println!("Txt2 kept byte-for-byte in {checked} Photoshop type files");
}
/// A text layer created in Photocraft (no PSD data) exports as an editable type layer and
/// A text layer created in PhotoCraft (no PSD data) exports as an editable type layer and
/// imports back with the same model.
#[test]
fn created_text_layer_roundtrips_through_psd() {
+1 -1
View File
@@ -678,7 +678,7 @@ impl BrushSettings {
pub struct BrushPreset {
pub name: String,
pub brush: BrushSettings,
/// Shipped with Photocraft (built-ins can be deleted from a session but are regenerated on reset).
/// Shipped with PhotoCraft (built-ins can be deleted from a session but are regenerated on reset).
#[serde(default)]
pub builtin: bool,
/// Preset group (folder) in the Brushes panel, e.g. "General" or an imported file's name.
+40 -4
View File
@@ -102,6 +102,22 @@ impl PsdFile {
/// Errors are those of [`Self::from_bytes`]; a merged image that passes the parse's check but
/// cannot be decoded is the inner `Err`, as [`Self::decode_merged`] would return it.
pub fn from_bytes_and_merged(data: &[u8], want: impl FnOnce(&PsdFile) -> bool) -> Result<(Self, Option<Result<Vec<u8>>>)> {
Self::from_bytes_and_merged_recovering(data, want, |_| false)
}
/// Parses like [`Self::from_bytes_and_merged`], but lets `recover` accept damage confined to
/// the merged image after all layer records and channel data have been read successfully.
/// Callers should opt in only when they can reconstruct the document from its layers.
///
/// The original encoded image bytes are preserved and its parse error is returned as
/// `Some(Err(..))`, even when `want` did not request a decode. The caller must report that
/// error rather than silently discarding the merged image (which may hold extra channels).
/// Header/layer errors, missing compression fields and parse safety-limit errors stay fatal.
pub fn from_bytes_and_merged_recovering(
data: &[u8],
want: impl FnOnce(&PsdFile) -> bool,
recover: impl FnOnce(&PsdFile) -> bool,
) -> Result<(Self, Option<Result<Vec<u8>>>)> {
let mut r = Reader::new(data);
let header = Header::read(&mut r)?;
let psb = header.version.is_psb();
@@ -161,10 +177,30 @@ impl PsdFile {
let image_data = ImageData { compression: Compression::Raw, data: Vec::new() };
let mut file =
PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data };
let merged = if want(&file) { Some(file.read_merged_decoded(&mut r)?) } else { None };
if merged.is_none() {
file.image_data = ImageData::read(&mut r, &file.header)?;
}
let mut encoded = r.clone();
let merged = if want(&file) {
file.read_merged_decoded(&mut r).map(Some)
} else {
ImageData::read(&mut r, &file.header).map(|image| {
file.image_data = image;
None
})
};
let merged = match merged {
Ok(merged) => merged,
Err(error) => {
if matches!(error, PsdError::LimitExceeded(_)) || !recover(&file) {
return Err(error);
}
let compression = Compression::from_u16(encoded.u16()?);
let source = encoded.peek_rest();
let mut data = Vec::new();
data.try_reserve_exact(source.len()).map_err(|_| PsdError::LimitExceeded("not enough memory to retain the damaged merged image"))?;
data.extend_from_slice(source);
file.image_data = ImageData { compression, data };
Some(Err(error))
}
};
Ok((file, merged))
}
+2 -1
View File
@@ -21,7 +21,8 @@ fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
/// Files known to be invalid upstream, with the reason. They are reported but
/// do not fail the test.
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
const KNOWN_BAD: &[(&str, &str)] =
&[("group-divider-blend-mode.psd", "malformed merged image data; strict parsing rejects it, but the importer recovers its group (#6152)")];
#[test]
fn corpus_parse_and_byte_stable() {
+43
View File
@@ -0,0 +1,43 @@
//! Opt-in merged-image recovery must not weaken strict parsing or lose encoded bytes.
use photocraft_psd::{Compression, PsdError, PsdFile, Version, compression, testgen};
#[test]
fn recovery_is_explicit_and_preserves_the_original_file() {
for version in [Version::Psd, Version::Psb] {
for compression in Compression::ALL {
let mut original = testgen::small(version, compression);
original.image_data.data.pop();
let bytes = original.to_bytes().unwrap();
let strict_error = PsdFile::from_bytes(&bytes).unwrap_err();
for want in [false, true] {
let rejected = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| false).unwrap_err();
assert_eq!(rejected, strict_error);
let (file, merged) = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true).unwrap();
assert_eq!(merged.unwrap().unwrap_err(), strict_error);
assert_eq!(file, original);
assert_eq!(file.to_bytes().unwrap(), bytes, "{version:?} {compression:?}");
}
}
}
}
#[test]
fn recovery_does_not_accept_missing_compression_or_incomplete_layers() {
let original = testgen::small(Version::Psd, Compression::Raw);
let bytes = original.to_bytes().unwrap();
let merged_offset = bytes.len() - original.image_data.data.len() - 2;
for cut in [merged_offset - 1, merged_offset, merged_offset + 1] {
assert!(PsdFile::from_bytes_and_merged_recovering(&bytes[..cut], |_| true, |_| true).is_err(), "cut {cut}");
}
}
#[test]
fn recovery_never_suppresses_a_merged_stream_limit_error() {
let mut original = testgen::small(Version::Psd, Compression::Zip);
original.image_data.data = compression::zip_compress(&vec![0; (1 << 20) + 4096]);
let bytes = original.to_bytes().unwrap();
for want in [false, true] {
assert!(matches!(PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true), Err(PsdError::LimitExceeded(_))));
}
}
+10
View File
@@ -14,6 +14,16 @@ fn main() {
use photocraft_tablet::Sample;
use photocraft_tablet::macos::Monitor;
// Answer cargo-nextest's `--list` (and nothing for `--ignored`), as tests/mac_menu_appkit.rs
// in apps/photocraft does; `monitor_macos --exact` falls through to the test.
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--list") {
if !args.iter().any(|a| a == "--ignored") {
println!("monitor_macos: test");
}
return;
}
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
let app = NSApplication::sharedApplication(mtm);
let event = |kind: CGEventType, pressure: f64, tilt: (f64, f64), subtype: CGEventMouseSubtype| {
+4 -2
View File
@@ -14,5 +14,7 @@ photocraft-raster = { workspace = true }
serde_json = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# In-process resident memory for the benchmarks (`perf`; MIT OR Apache-2.0).
memory-stats = "1.2"
# In-process resident memory for the benchmarks (`perf`; MIT OR Apache-2.0). `always_use_statm`:
# on Linux read /proc/self/statm, not /proc/self/smaps, which walks every mapping under the mmap
# lock and made the 2 ms sampler thread a fifth of a large bench's CPU time.
memory-stats = { version = "1.2", features = ["always_use_statm"] }
+1 -1
View File
@@ -35,7 +35,7 @@ pub static INTER_MEDIUM: LazyLock<Vec<u8>> = LazyLock::new(|| inflate_font(bundl
pub static INTER_SEMIBOLD: LazyLock<Vec<u8>> = LazyLock::new(|| inflate_font(bundled_font!("Inter-SemiBold.ttf")));
pub static JETBRAINS_MONO_REGULAR: LazyLock<Vec<u8>> = LazyLock::new(|| inflate_font(bundled_font!("JetBrainsMono-Regular.ttf")));
/// Fonts shipped with Photocraft (OFL; licences in `assets/fonts`).
/// Fonts shipped with PhotoCraft (OFL; licences in `assets/fonts`).
pub static BUNDLED: [(&str, &LazyLock<Vec<u8>>); 4] = [
("Inter-Regular.ttf", &INTER_REGULAR),
("Inter-Medium.ttf", &INTER_MEDIUM),
+6
View File
@@ -586,6 +586,12 @@ impl Layouter {
for p in style_props(&out.styles[si0], k, &fallback, si0 as u32, small_caps[si0]) {
b.push_default(p);
}
// Paragraph text breaks a word wider than the box at the box edge, as Photoshop
// does (#6694): parley's default only breaks at UAX #14 opportunities and lets the
// word run out of the box. Point text has no width to overflow.
if is_box {
b.push_default(StyleProperty::OverflowWrap(parley::style::OverflowWrap::BreakWord));
}
for (ri, st) in out.styles.iter().enumerate() {
let rs = run_starts[ri];
let (a, z) = (rs.max(prange.start), (rs + runs[ri].len).min(content_end));
+1 -1
View File
@@ -1,4 +1,4 @@
//! Photocraft's type engine.
//! PhotoCraft's type engine.
//!
//! * [`fonts::FontDb`]: bundled fonts (Inter, JetBrains Mono; always available, including on the
//! web), optional system fonts from platform directory scans plus Fontconfig's Linux file list,
+34
View File
@@ -2008,3 +2008,37 @@ fn hyphenless_postscript_names_split_trailing_styles() {
assert_eq!((r.family.as_str(), r.weight, r.italic), (family, weight, italic), "{ps}");
}
}
/// A word wider than its paragraph box wraps at the box edge instead of running past it (#6694);
/// a normal break opportunity still wins, and a box that fits the word lays out exactly as before.
#[test]
fn a_word_wider_than_the_paragraph_box_breaks_at_the_box_edge() {
let mut e = TextEngine::new();
let word = "BLOQUEADO?!";
let alone = e.layout(&point(word, 12.0), 72.0);
assert_eq!(alone.lines.len(), 1, "{:?}", alone.lines);
let word_width = width(&alone);
let narrow = 30.0;
assert!(word_width > narrow * 2.0, "{word_width}");
let mut t = point(word, 12.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: narrow, height: 200.0 };
let l = e.layout(&t, 72.0);
assert!(l.lines.len() >= 2, "{:?}", l.lines);
for line in &l.lines {
assert!(line.x1 <= narrow + 0.5, "{line:?}");
}
let joined: String = l.lines.iter().map(|l| &word[l.range.clone()]).collect();
assert_eq!(joined, word);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: word_width + 20.0, height: 200.0 };
let fits = e.layout(&t, 72.0);
assert_eq!(fits.lines.len(), 1, "{:?}", fits.lines);
assert!((width(&fits) - word_width).abs() < 0.01, "{} vs {word_width}", width(&fits));
let mut spaced = point("ab cd", 12.0);
spaced.shape = TextShape::Box { x: 0.0, y: 0.0, width: narrow, height: 200.0 };
let l = e.layout(&spaced, 72.0);
assert_eq!(l.lines.len(), 2, "{:?}", l.lines);
assert_eq!(l.lines[1].range.start, 3, "{:?}", l.lines);
}
+1 -1
View File
@@ -47,7 +47,7 @@ sys-locale = "=0.3.2"
# Installed RAM for the GPU memory budget (`gpu_canvas::physical_memory`): the safe binding the
# desktop app already uses, pinned to the same version.
[target.'cfg(target_os = "windows")'.dependencies]
winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] }
winsafe = { version = "=0.0.29", default-features = false, features = ["kernel", "dxgi"] }
[dev-dependencies]
# egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with
+10 -2
View File
@@ -200,9 +200,17 @@ fn main() {
.map(|s| s.tiles().map(|(_, t)| t.bytes().len() as u64).sum::<u64>())
.sum();
let ram = photocraft_ui_egui::gpu_canvas::physical_memory();
let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram);
let vram = g.video_memory();
let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram, vram, 0);
g.set_memory_budget(budget);
println!("RAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB", ram.unwrap_or(0) >> 20, pixels >> 20, allowance >> 20, budget >> 20);
println!(
"RAM {} MB, VRAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB",
ram.unwrap_or(0) >> 20,
vram.unwrap_or(0) >> 20,
pixels >> 20,
allowance >> 20,
budget >> 20
);
let first_view = Rect::from_xywh((w as f32 * 0.1) as i32, (h as f32 * 0.1) as i32, 2560, 1440);
g.set_focus(Some(if op == "view" { first_view } else { doc.bounds() }));
+67 -1
View File
@@ -242,6 +242,8 @@ struct Sizes {
big_layer: (u32, u32),
/// Layers in the "150-layer document".
many_layers: usize,
/// The full-canvas layers document (L3): size and layer count.
full_layers: (u32, u32, usize),
/// The 16-bit document (15000 × 10000).
huge16: (u32, u32),
/// The 32-bit document with 20 layers.
@@ -258,6 +260,7 @@ const FULL: Sizes = Sizes {
layered: (5472, 3648),
big_layer: (4243, 2828),
many_layers: 150,
full_layers: (3000, 2000, 150),
huge16: (15000, 10000),
float32: (4000, 3000),
ten_mp: (3872, 2592),
@@ -269,6 +272,7 @@ const QUICK: Sizes = Sizes {
layered: (1600, 1066),
big_layer: (1200, 800),
many_layers: 40,
full_layers: (800, 600, 20),
huge16: (2400, 1600),
float32: (1200, 900),
ten_mp: (1200, 800),
@@ -357,9 +361,10 @@ fn main() {
// A panic in one group (a crash the scenario found) is reported against the row that was
// running, and the other groups still run.
type Group = fn(&mut Bench, &Sizes);
let groups: [(&str, Group); 5] = [
let groups: [(&str, Group); 6] = [
("layered", layered_scenarios),
("many layers", many_layer_scenarios),
("full-canvas layers", full_canvas_layer_scenarios),
("bit depths", depth_scenarios),
("long operations", job_scenarios),
("algorithms", algorithm_scenarios),
@@ -538,6 +543,67 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) {
});
}
/// Layers overhaul L3: edits that redraw the whole document (a layer's opacity, hiding it) with
/// many full-canvas layers, under the app's own GPU memory budget (Memory Usage at its default,
/// the adapter's kind; `gpu_canvas::memory_budget`). Past what the budget holds, each one went to
/// the CPU compositor (2.6 s at 150 layers of 6 MP on a discrete card).
fn full_canvas_layer_scenarios(b: &mut Bench, sz: &Sizes) {
let opacity = "opacity of a layer among many full-canvas layers + refresh";
let hide = "hide a layer among many full-canvas layers + refresh";
if !b.wanted(opacity) && !b.wanted(hide) {
return;
}
b.current = Some(opacity.to_string());
let (w, h, n) = sz.full_layers;
let mut s = Session::new();
let built = (|| -> Res<u64> {
exec(&mut s, "file.new", json!({"width": w, "height": h, "background": "white"}))?;
let mut mid = 0;
for i in 0..n as u32 {
exec(&mut s, "layer.new.layer", json!({"name": format!("full {i}")}))?;
exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}{:02x}80", (i * 37) % 256, (i * 91) % 256), "opacity": 3}))?;
if i % 7 == 3 {
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 6, "size": 8}))?;
}
if i as usize == n / 2 {
mid = active_layer(&s)?;
}
}
Ok(mid)
})();
let mid = match built {
Ok(v) => v,
Err(e) => {
b.errors.push((opacity.to_string(), e));
return;
}
};
if let (Some((g, _)), Some(st)) = (&b.gpu, s.active()) {
// As the app counts it (`canvas::gpu_budget`).
let pixels = st.memory_bytes() as u64;
let allowance = u64::from(photocraft_engine::prefs::Performance::default().memory_usage_mb) << 20;
g.set_memory_budget(photocraft_ui_egui::gpu_canvas::memory_budget(
allowance,
pixels,
photocraft_ui_egui::gpu_canvas::physical_memory(),
g.video_memory(),
0,
));
}
let _ = b.refresh(&s, true);
let reps = b.reps;
b.time(opacity, &mut s, reps, true, |b, s, i| {
let t = Instant::now();
exec(s, "layer.setProps", json!({"layer": mid, "opacity": if i % 2 == 0 { 0.5 } else { 1.0 }}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
b.time(hide, &mut s, reps, true, |b, s, i| {
let t = Instant::now();
exec(s, "layer.setProps", json!({"layer": mid, "visible": i % 2 == 1}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
}
/// #209: arrow-key nudge in the 150-layer document; #1771: a new layer there.
fn many_layer_scenarios(b: &mut Bench, sz: &Sizes) {
// Row names stay stable across modes: the layer count is in the mode, not the key.
+1 -1
View File
@@ -1,4 +1,4 @@
//! Render the full Photocraft UI offscreen (no window, no focus stealing) and save a PNG.
//! Render the full PhotoCraft UI offscreen (no window, no focus stealing) and save a PNG.
//!
//! ```sh
//! cargo run --release -p photocraft-ui-egui --example snapshot -- \
+15 -10
View File
@@ -41,16 +41,21 @@ fn gpu_app() -> Option<Gpu> {
let observer = Arc::new(std::sync::Mutex::new(None));
let observer_out = observer.clone();
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
*observer_out.lock().expect("observer") = Some(GpuCanvas::new(rs));
app.set_wgpu(rs.clone());
}
app.background_jobs = true;
app
})
egui_kittest::Harness::builder()
.with_size(egui::vec2(1000.0, 700.0))
.with_pixels_per_point(1.0)
.with_max_steps(64)
.wgpu_setup(crate::gpu_canvas::wgpu_setup())
.build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
*observer_out.lock().expect("observer") = Some(GpuCanvas::new(rs));
app.set_wgpu(rs.clone());
}
app.background_jobs = true;
app
})
}));
let Ok(harness) = built else {
eprintln!("skipping: no GPU adapter");
+71 -10
View File
@@ -15,6 +15,13 @@ use crate::state::{Tool, View};
/// Largest texture side we upload; bigger documents display downsampled until the GPU path lands.
pub const MAX_TEXTURE: u32 = 4096;
/// The largest texture side to upload on this device: [`MAX_TEXTURE`], or less where the GPU
/// allows less (2048 on some older or low-end GPUs and WebGL contexts). Past the device's limit
/// egui asserts and the backend fails to create the texture.
fn texture_side(ctx: &egui::Context) -> u32 {
u32::try_from(ctx.input(|i| i.max_texture_side)).unwrap_or(MAX_TEXTURE).clamp(1, MAX_TEXTURE)
}
pub(crate) struct ClonePreviewCache {
key: String,
texture: egui::TextureHandle,
@@ -1421,7 +1428,7 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
if cache.tex_revision != revision || cache.texture.is_none() || cache.tex_preview_key != preview_key {
let t0 = crate::gpu_canvas::now_ms();
let longest = doc.size.width.max(doc.size.height);
let factor = longest.div_ceil(MAX_TEXTURE).max(1);
let factor = longest.div_ceil(texture_side(ctx)).max(1);
let (w, h) = ((doc.size.width / factor).max(1), (doc.size.height / factor).max(1));
let current = cache.texture.as_mut().filter(|t| t.size() == [w as usize, h as usize]);
if let (Some(d), Some(t)) = (damage, current) {
@@ -1608,7 +1615,21 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
grid: None,
});
let present = cache.on_gpu && gpu.has(id.0, size);
if present && cache.revision == revision && cache.preview_key == preview_key {
let current = cache.revision == revision && cache.preview_key == preview_key;
// GPU Memory or Memory Usage changed with nothing to redraw: the new budget, and whether the
// layers fit it, show at once (the status bar's GPU Memory Full).
if present && app.perf.gpu_budget_allowance != budget_allowance(app) {
gpu_budget(app, &gpu, idx, false, visible);
}
// Switched to a document whose canvas is already current: size the budget for it and measure
// whether its layers fit, so GPU Memory Full shows (or clears) at once rather than at its
// next full refresh. Once per switch: the fit then names this document.
if present && app.perf.gpu_layer_fit.is_none_or(|f| f.doc != id.0) {
gpu_budget(app, &gpu, idx, false, visible);
let fit = gpu.measure_fit(&doc).unwrap_or(crate::gpu_canvas::LayerFit { doc: id.0, needed: 0, budget: 0 });
app.perf.gpu_layer_fit = Some(fit);
}
if present && current {
// An adjustment preview composited where the view was: catch up where it moved to.
if let Some(r) = crate::adjust_preview::uncovered(app, id, raw_key, visible) {
let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref());
@@ -1620,12 +1641,17 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
// draws it (checked before the memory budget, which walks the history, every frame).
if !gpu.fits_canvas(&doc) {
crate::background_redraw::cancel(app, id);
// Drawn on the CPU canvas: no layer budget applies to it.
if app.perf.gpu_layer_fit.is_some_and(|f| f.doc == id.0) {
app.perf.gpu_layer_fit = None;
}
return false;
}
let partial = present && damage.is_some();
gpu_budget(app, &gpu, idx, partial, visible);
let defer = crate::background_redraw::enabled(app);
let r = gpu.refresh_with(id.0, &doc, if partial { damage } else { None }, display.as_deref(), defer);
app.perf.gpu_layer_fit = gpu.layer_fit();
if r.kind == "lost" || r.kind == crate::gpu_canvas::REFRESH_OVER_BUDGET {
// The device is gone (`gpu_status` then drops the GPU canvas) or the canvas is over the
// texture budget: draw this frame on the CPU path.
@@ -1666,21 +1692,32 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
true
}
/// Point the wgpu compositor at what the view shows, and size its memory budget from Memory
/// Usage, the document's pixels and History, and physical memory: on full refreshes (pixels
/// counted once per structural change, not per brush dab) and whenever the preference changes.
/// Point the wgpu compositor at what the view shows, and size its memory budget from GPU Memory,
/// or Memory Usage, the document's pixels and History (charged where the GPU shares the system's
/// memory), physical memory and the GPU's own: on full refreshes (pixels counted once per
/// structural change, not per brush dab) and whenever either preference changes.
fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: usize, partial: bool, visible: DRect) {
gpu.set_focus(Some(visible));
let allowance = u64::from(app.session.prefs().performance.memory_usage_mb).saturating_mul(1 << 20);
if partial && app.perf.gpu_budget_allowance == allowance && gpu.memory_budget().is_some() {
let allowance = budget_allowance(app);
let changed = app.perf.gpu_budget_allowance != allowance;
if partial && !changed && gpu.memory_budget().is_some() {
return;
}
let Some(st) = app.session.documents().get(idx) else { return };
let pixels = st.memory_bytes() as u64;
let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory());
let budget = crate::gpu_canvas::memory_budget(allowance.0, pixels, crate::gpu_canvas::physical_memory(), gpu.video_memory(), allowance.1);
gpu.set_memory_budget(budget);
app.perf.gpu_budget_mb = budget >> 20;
app.perf.gpu_budget_allowance = allowance;
if changed && let Some(fit) = gpu.measure_fit(&st.doc) {
app.perf.gpu_layer_fit = Some(fit);
}
}
/// Memory Usage and GPU Memory (Preferences › Performance), in bytes.
fn budget_allowance(app: &PhotocraftApp) -> (u64, u64) {
let performance = &app.session.prefs().performance;
(u64::from(performance.memory_usage_mb).saturating_mul(1 << 20), u64::from(performance.gpu_memory_mb).saturating_mul(1 << 20))
}
/// Live preview for an open filter dialog: run the filter on the proxy and upload it. With
@@ -4054,6 +4091,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
if primary && hand_pan && response.dragged() {
crate::transform_tool::draw_content(app, &painter, &xf);
crate::transform_tool::draw_overlay(app, &painter, &xf);
// The Pen's path in progress survives a Space pan, and stays on screen through it (#6592).
crate::vector_ui::draw_overlay(app, &painter, &xf, &doc);
}
app.tool_override = None;
if let Some(b) = layer_edges_rect(app, idx) {
@@ -4520,8 +4559,8 @@ fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie
None => {
let t0 = crate::gpu_canvas::now_ms();
let extent = photocraft_engine::extra_cmds::reveal_all_bounds(&doc);
// One texel per `k` document pixels keeps every texture within MAX_TEXTURE.
let k = extent.width().max(extent.height()).div_ceil(MAX_TEXTURE).max(1);
// One texel per `k` document pixels keeps every texture within the device's side.
let k = extent.width().max(extent.height()).div_ceil(texture_side(&ctx)).max(1);
let proxy = (k > 1).then(|| photocraft_compose::proxy::proxy_document(&doc, k));
let src = proxy.as_ref().unwrap_or(&*doc);
let k = k as i32;
@@ -5459,6 +5498,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
{
e.dragging = false;
e.resize = None;
app.ui.caret_blink_time = app.last_frame_time;
}
if tool == Tool::Pen {
crate::vector_ui::pen_up(app);
@@ -6525,6 +6565,25 @@ mod tests {
assert_eq!(uploads(&ctx), vec![(true, crisp)], "a partial update keeps the crisp sampling");
}
/// A device that allows smaller textures than MAX_TEXTURE (2048 on some older GPUs and WebGL
/// contexts, and on the fallback adapter the GPU tests sometimes get under load): the canvas
/// texture is reduced to fit it. A 2600 px document was uploaded at full size, which egui
/// asserts on and the backend can't create.
#[test]
fn cpu_canvas_texture_fits_the_device_limit() {
for (side, (w, h), want) in [(2048, (2600, 1700), [1300, 850]), (2048, (5000, 2000), [1666, 666]), (8192, (5000, 2000), [2500, 1000])] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": w, "height": h})).unwrap();
let ctx = egui::Context::default();
ctx.input_mut(|i| i.max_texture_side = side);
let (tex, _) = ensure_texture(&mut app, &ctx, 0, None).unwrap();
let mut delta = ctx.tex_manager().write().take_delta();
let sizes: Vec<_> = delta.set.get(&tex).into_iter().flatten().map(|d| d.image.size()).collect();
delta.clear();
assert_eq!(sizes, vec![want], "{w}x{h} on a {side} px device");
}
}
#[test]
fn cpu_canvas_of_a_reduced_document_stays_bilinear() {
// Over MAX_TEXTURE the texture holds one texel per `factor` pixels: a texel is not a
@@ -6542,6 +6601,7 @@ mod tests {
#[test]
fn gpu_display_luts_are_per_display() {
let _gpu = photocraft_testkit::gpu_lock();
let Ok(rs) = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) else {
eprintln!("skipping: no GPU adapter");
return;
@@ -7460,6 +7520,7 @@ mod tests {
// must update only the reduced pixels it touched.
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
ctx.input_mut(|i| i.max_texture_side = 8192);
app.run("file.new", json!({"width": 4097, "height": 90, "background": "transparent"})).unwrap();
let tilt = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "shapeDynamics": {"enabled": true, "size": {"control": "penTilt"}}});
app.run("tools.setBrush", json!({ "brush": tilt })).unwrap();
+51
View File
@@ -183,6 +183,24 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
status_message(app, ui, &t);
}
/// The active document's layers don't fit the GPU memory budget (`perf.gpuLayerFit`): edits that
/// redraw the whole document do it on the CPU, so say so in the status bar until they fit again.
/// Clicking it opens Preferences › Performance, where GPU Memory raises the budget.
pub fn gpu_memory_status(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let Some(doc) = app.session.active().map(|st| st.doc.id.0) else { return };
let Some(fit) = app.perf.gpu_layer_fit.filter(|f| f.doc == doc && !f.fits()) else { return };
let t = Tokens::get(ui.ctx());
let resp = ui
.add(egui::Label::new(RichText::new(tl!("GPU Memory Full")).color(t.warning).size(12.0)).sense(Sense::click()))
.on_hover_text(crate::i18n::fmt(
tl!("The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory."),
&[("needed", &fmt_bytes(fit.needed)), ("budget", &fmt_bytes(fit.budget))],
));
if resp.clicked() {
crate::shortcut_dispatch::dispatch(app, ui.ctx(), "edit.preferences.performance");
}
}
/// The latest status message after a separator, in the warning colour when it is an error.
fn status_message(app: &PhotocraftApp, ui: &mut egui::Ui, t: &Tokens) {
if app.ui.status.is_empty() {
@@ -293,6 +311,39 @@ mod tests {
assert_eq!(status_info_text_with_units(&d, "dimensions", "", "", &units), "722.7 pt x 451.7 pt (240 ppi)");
}
/// L3: layers over the GPU memory budget show "GPU Memory Full" in the status bar, for that
/// document only, and clicking it opens Preferences › Performance (GPU Memory).
#[test]
fn status_bar_says_when_layers_dont_fit_gpu_memory() {
use egui_kittest::{Harness, kittest::Queryable};
for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] {
let mut h = Harness::builder().with_size(vec2(1200.0, 700.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.ui.theme = theme;
app
});
h.state_mut().run("file.new", json!({"width": 800, "height": 600})).unwrap();
h.state_mut().sync_views();
h.run_steps(4);
let doc = h.state().session.active().unwrap().doc.id.0;
assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: nothing measured yet");
let over = crate::gpu_canvas::LayerFit { doc, needed: 3 << 30, budget: 2 << 30 };
h.state_mut().perf.gpu_layer_fit = Some(crate::gpu_canvas::LayerFit { doc: doc + 1, ..over });
h.run_steps(2);
assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: another document's layers");
h.state_mut().perf.gpu_layer_fit = Some(crate::gpu_canvas::LayerFit { budget: 4 << 30, ..over });
h.run_steps(2);
assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: they fit");
h.state_mut().perf.gpu_layer_fit = Some(over);
h.run_steps(2);
h.get_by_label("GPU Memory Full").click();
h.run_steps(4);
let open = h.state().ui.dialogs.iter().any(|d| d.fields.get("section") == Some(&json!("performance")));
assert!(open, "{theme:?}: Preferences › Performance opens");
}
}
#[test]
fn status_bar_updates_dimensions_after_a_ruler_preference_change() {
use egui_kittest::{Harness, kittest::Queryable};
@@ -83,6 +83,7 @@ fn alt_click_clipping_still_creates_releases_and_undoes_without_moving_rows() {
#[test]
#[ignore = "writes synthetic English Layers-panel screenshots for before/after inspection"]
fn write_clipping_indicator_preview() {
let _gpu = photocraft_testkit::gpu_lock();
let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plan/evidence/clipping");
std::fs::create_dir_all(&output).unwrap();
for theme in crate::theme::ThemeKind::ALL {
+70 -23
View File
@@ -171,7 +171,7 @@ pub fn open(app: &mut PhotocraftApp, target: &str) -> u64 {
let c = if target == "background" { app.session.tools.background } else { app.session.tools.foreground };
let rgb = [c[0], c[1], c[2]];
let label = if target == "background" { "Color Picker (Background Color)" } else { "Color Picker (Foreground Color)" };
let mut fields = initial_fields(target, label, rgb);
let mut fields = fields_with_web(app, target, label, rgb);
// A colour that was picked as CMYK shows the inks it was picked as, not what its sRGB stand-in converts to (#4196).
if let Some(cmyk) = app.session.tools.picked_cmyk(rgb) {
fields.insert("__cmyk".into(), json!(cmyk));
@@ -192,10 +192,23 @@ pub(crate) fn initial_fields(target: &str, label: &str, rgb: [f32; 3]) -> Map<St
f
}
/// Seed [`initial_fields`] with the last Only Web Colors setting. With web-only on, the opening
/// colour is snapped and `__orig` follows it: OK without an edit is "unchanged", so opening the
/// picker never rewrites a fill behind the user's back (#4995).
pub(crate) fn fields_with_web(app: &PhotocraftApp, target: &str, label: &str, rgb: [f32; 3]) -> Map<String, Value> {
let mut f = initial_fields(target, label, rgb);
f.insert("__webOnly".into(), json!(app.ui.color_panel.web_only));
if app.ui.color_panel.web_only {
set_rgb(&mut f, rgb, Keep::Nothing);
f.insert("__orig".into(), f["color"].clone());
}
f
}
/// Open the picker on `rgb` for something other than the tool colours: OK runs `command` with
/// `params` plus `"color": "#rrggbb"` (e.g. a gradient stop's colour).
pub fn open_for_command(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], command: &str, params: Value) -> u64 {
let mut f = initial_fields("command", label, rgb);
let mut f = fields_with_web(app, "command", label, rgb);
f.insert("__command".into(), json!(command));
f.insert("__params".into(), params);
app.ui.open_dialog(DialogKind::Command, f)
@@ -204,33 +217,16 @@ pub fn open_for_command(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], com
/// Open the picker on `rgb` for a colour field of another dialog (e.g. Edit › Fill's Color…): OK
/// writes `"#rrggbb"` into `field` of dialog `dialog`, which stays open; Cancel leaves it alone.
pub fn open_for_field(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], dialog: u64, field: &str) -> u64 {
let hsv = rgb_to_hsv(rgb);
let mut f = Map::new();
f.insert("__colorPicker".into(), json!("field"));
f.insert("__label".into(), json!(label));
let mut f = fields_with_web(app, "field", label, rgb);
f.insert("__dialog".into(), json!(dialog));
f.insert("__field".into(), json!(field));
f.insert("color".into(), json!(hex(rgb)));
f.insert("__orig".into(), json!(hex(rgb)));
f.insert("__hsv".into(), json!(hsv));
f.insert("__mode".into(), json!("h"));
f.insert("__webOnly".into(), json!(false));
app.ui.open_dialog(DialogKind::Command, f)
}
/// Open the picker on `rgb` for a colour the shell keeps itself (e.g. the Crop shield's custom
/// colour): OK hands `"#rrggbb"` to `target`'s setter in [`confirm`]; Cancel leaves it alone.
pub fn open_for_target(app: &mut PhotocraftApp, target: &str, label: &str, rgb: [f32; 3]) -> u64 {
let hsv = rgb_to_hsv(rgb);
let mut f = Map::new();
f.insert("__colorPicker".into(), json!(target));
f.insert("__label".into(), json!(label));
f.insert("color".into(), json!(hex(rgb)));
f.insert("__orig".into(), json!(hex(rgb)));
f.insert("__hsv".into(), json!(hsv));
f.insert("__mode".into(), json!("h"));
f.insert("__webOnly".into(), json!(false));
app.ui.open_dialog(DialogKind::Command, f)
app.ui.open_dialog(DialogKind::Command, fields_with_web(app, target, label, rgb))
}
/// Set by a dialog body (to the picker's title) to open the Color Picker on its `color` field.
@@ -367,7 +363,7 @@ pub fn current(f: &Map<String, Value>) -> Components {
fn set_rgb(f: &mut Map<String, Value>, rgb: [f32; 3], keep: Keep) {
let web = f.get("__webOnly").and_then(Value::as_bool).unwrap_or(false);
let (rgb, keep) = if web { (rgb.map(|v| (v * 5.0).round() / 5.0), Keep::Nothing) } else { (rgb, keep) };
let (rgb, keep) = if web { (web_safe(rgb), Keep::Nothing) } else { (rgb, keep) };
f.insert("color".into(), json!(hex(rgb)));
for key in ["__hsv", "__lab", "__cmyk"] {
f.remove(key);
@@ -688,9 +684,14 @@ fn fields(ui: &mut egui::Ui, f: &mut Map<String, Value>, mode: &str, now: &Compo
let focused = ui.memory(|m| m.has_focus(id));
let pasted = if focused { take_hex_paste(ui) } else { None };
let typing = if focused { ui.data(|d| d.get_temp::<String>(id)) } else { None };
// Drop stale typing when another path (web snap, field drag, Current) changed `color`.
let shown = hex(rgb);
let mut h = match pasted {
Some(c) => hex(c).trim_start_matches('#').to_ascii_uppercase(),
None => typing.unwrap_or_else(|| hex(rgb).trim_start_matches('#').to_ascii_uppercase()),
None => match typing {
Some(t) if parse_hex(&t).is_none() || parse_hex(&t).map(hex).as_deref() == Some(shown.as_str()) => t,
_ => shown.trim_start_matches('#').to_ascii_uppercase(),
},
};
let r = crate::widgets::text_field_ex(ui, id, &mut h, "", 64.0, true, Some(6));
if let Some(c) = pasted {
@@ -783,6 +784,9 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
if crate::layer_style::color_picker::owns(f) {
return crate::layer_style::color_picker::confirm(app, f);
}
if crate::solid_fill_ui::owns_new(f) {
return crate::solid_fill_ui::confirm_new(app, f);
}
if crate::solid_fill_ui::owns(f) {
return crate::solid_fill_ui::confirm(app, f);
}
@@ -1041,6 +1045,7 @@ mod tests {
h.get_by_label("Only Web Colors").click();
h.run_steps(2);
assert_eq!(color(&h), "#003366");
assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("003366"), "hex field follows the snap while it has focus");
h.get_by_label("Only Web Colors").click();
h.run_steps(2);
h.get_by_label("Click to restore the current colour").click();
@@ -1048,6 +1053,48 @@ mod tests {
assert_eq!(color(&h), "#123456");
}
#[test]
fn web_only_snaps_hex_field_and_component_edits() {
let mut f = Map::new();
f.insert("color".into(), json!("#123456"));
f.insert("__webOnly".into(), json!(true));
set_rgb(&mut f, parse_hex("#ff8800").unwrap(), Keep::Nothing);
assert_eq!(f["color"], json!("#ff9900"));
set_rgb(&mut f, hsv_to_rgb(40.0, 1.0, 1.0), Keep::Hsv([40.0, 1.0, 1.0]));
assert_eq!(f["color"], json!("#ff9900"));
assert!(f.get("__hsv").is_none(), "kept HSV does not outlive a web snap");
set_rgb(&mut f, photocraft_color::convert::lab_to_srgb([50.0, 40.0, 20.0]).map(|c| c.clamp(0.0, 1.0)), Keep::Lab([50.0, 40.0, 20.0]));
assert_eq!(not_web_safe(parse_hex(f["color"].as_str().unwrap()).unwrap()), None);
assert!(f.get("__lab").is_none());
}
#[test]
fn web_only_survives_reopen_and_other_picker_targets() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.ui.color_panel.web_only = true;
let id = open(&mut app, "foreground");
let f = &app.ui.dialog_mut(id).unwrap().fields;
assert_eq!(f["__webOnly"], json!(true));
assert_eq!(f["color"], json!(hex(web_safe([app.session.tools.foreground[0], app.session.tools.foreground[1], app.session.tools.foreground[2]]))));
assert_eq!(f["__orig"], f["color"], "the opening snap moves __orig too, so OK with no edit is unchanged");
app.ui.dialogs.clear();
let id = open_for_target(&mut app, "noteColor", "Color Picker", [1.0, 0.53, 0.0]);
let f = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields;
assert_eq!(f["__webOnly"], json!(true));
assert_eq!(f["color"], json!("#ff9900"));
assert_eq!(f["__orig"], json!("#ff9900"), "a non-web-safe start colour is not committed by opening the picker");
}
#[test]
fn sample_at_snaps_when_only_web_colors_is_on() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let id = open(&mut app, "foreground");
let f = &mut app.ui.dialog_mut(id).unwrap().fields;
f.insert("__webOnly".into(), json!(true));
set_rgb(f, [0.07, 0.2, 0.34], Keep::Nothing);
assert_eq!(f["color"], json!("#003366"));
}
#[test]
fn side_buttons_and_keyboard_confirm_or_cancel_without_changing_the_other_color() {
for target in ["foreground", "background"] {
+38 -1
View File
@@ -131,8 +131,9 @@ pub enum Outcome {
/// field's value is validated before the first one is applied, so a typo, an unknown field, a
/// bad value or a bad nested key can't reply with success while nothing — or only half of it —
/// changed (#412).
pub const UI_SET_FIELDS: [&str; 37] = [
pub const UI_SET_FIELDS: [&str; 38] = [
"tool",
"strokeTrace",
"panels",
"dock",
"dockTabs",
@@ -492,6 +493,19 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
None => None,
};
let gradient_classic = bool_field(p, "gradientClassic")?;
// The stroke latency trace (`stroke_trace`), at runtime: on (stderr) or off. A CSV
// file is only for the user's own launch (`--stroke-trace <path>`): a path over the
// control channel would write outside the automation write root.
let stroke_trace = match p.get("strokeTrace") {
None | Some(Value::Null) => None,
Some(Value::Bool(b)) => Some(if *b { "stderr" } else { "off" }),
Some(Value::String(v)) => match v.trim() {
"stderr" | "on" | "true" => Some("stderr"),
"off" | "false" => Some("off"),
_ => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"; a CSV file is set at launch (--stroke-trace <path>)".into()),
},
Some(_) => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"".into()),
};
// The Eyedropper's options bar (#1649): Sample Size, Sample, Show Sampling Ring.
let eyedropper_size = match p.get("eyedropperSampleSize") {
Some(v) => Some(
@@ -663,6 +677,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
if let Some(mode) = gradient_blend {
app.ui.tool_options.gradient_blend_mode = mode;
}
if let Some(v) = &stroke_trace {
app.stroke_trace.set(v);
}
if let Some(classic) = gradient_classic {
app.ui.tool_options.gradient_classic = classic;
}
@@ -1385,6 +1402,26 @@ mod tests {
}
}
#[test]
fn ui_set_turns_the_stroke_trace_on_and_off_at_runtime() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
assert!(!app.stroke_trace.enabled());
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": true}))["ok"], true);
assert!(app.stroke_trace.enabled());
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": "off"}))["ok"], true);
assert!(!app.stroke_trace.enabled());
let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": 3, "tool": "Pencil"}));
assert_eq!(bad["ok"], false);
// A file path isn't accepted over the channel (it would write outside the automation
// write root); nothing is created.
let path = std::env::temp_dir().join(format!("photocraft-trace-{}.csv", std::process::id()));
let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": path.to_str().unwrap(), "tool": "Pencil"}));
assert_eq!(bad["ok"], false);
assert!(!path.exists());
assert!(!app.stroke_trace.enabled() && app.ui.tool != crate::state::Tool::Pencil, "a rejected call applies nothing");
}
#[test]
fn camera_raw_control_updates_and_commits_or_cancels_an_open_raw() {
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
+1 -1
View File
@@ -109,7 +109,7 @@ pub fn savable(o: &ToolOptions) -> Option<(Map<String, Value>, String)> {
if !(a.is_finite() && b.is_finite() && a > 0.0 && b > 0.0) {
return None;
}
let name = format!("{} : {}", crate::widgets::fmt_num(a), crate::widgets::fmt_num(b));
let name = format!("{} : {}", crop_size::fmt_ratio(a), crop_size::fmt_ratio(b));
let mut fields = Map::new();
fields.insert("crop_ratio".into(), json!(o.crop_ratio));
Some((fields, name))
+18 -1
View File
@@ -146,6 +146,14 @@ pub fn ratio(o: &ToolOptions, doc_dpi: f64) -> Option<f64> {
Some(w / h).filter(|k| k.is_finite() && *k > 0.0)
}
/// Decimals a custom ratio side keeps and shows: 1.414 : 1 (ISO A sizes) as in Photoshop (#6487).
pub const RATIO_DECIMALS: u32 = 3;
/// A ratio side as stored in a `crop_ratio` key ("1.414", "16").
pub fn fmt_ratio(v: f64) -> String {
crate::widgets::fmt_num_prec(v, RATIO_DECIMALS)
}
/// Editable W and H shown for a custom aspect ratio. An existing valid ratio is authoritative;
/// when either side is zero, use the in-progress values retained in ToolOptions.
pub fn custom_ratio_fields(o: &ToolOptions, doc_size: (f64, f64)) -> (f32, f32) {
@@ -170,7 +178,7 @@ pub fn set_custom_ratio_fields(o: &mut ToolOptions, width: f32, height: f32) {
o.crop_custom_ratio_width = width;
o.crop_custom_ratio_height = height;
if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 {
o.crop_ratio = format!("{}:{}", crate::widgets::fmt_num(f64::from(width)), crate::widgets::fmt_num(f64::from(height)));
o.crop_ratio = format!("{}:{}", fmt_ratio(f64::from(width)), fmt_ratio(f64::from(height)));
} else {
o.crop_ratio.clear();
}
@@ -347,6 +355,15 @@ mod tests {
assert_eq!(width / height, 4.0 / 3.0);
}
#[test]
fn custom_ratio_keeps_three_decimals() {
// #6487: 1.414 : 1 (ISO A sizes) stays 1.414, not 1.4, as in Photoshop.
let mut options = ToolOptions::default();
set_custom_ratio_fields(&mut options, 1.414, 1.0);
assert_eq!(options.crop_ratio, "1.414:1");
assert_eq!(custom_ratio_fields(&options, (0.0, 0.0)), (1.414, 1.0));
}
#[test]
fn selecting_free_ratio_clears_previous_custom_dimensions() {
let mut options = ToolOptions { crop_ratio: "16:9".into(), ..ToolOptions::default() };
+2 -1
View File
@@ -645,7 +645,7 @@ pub fn swapped(r: [f64; 4]) -> [f64; 4] {
/// no ratio is set; "original" becomes the document's size turned on its side.
pub fn swapped_ratio(key: &str, doc_w: f64, doc_h: f64) -> Option<String> {
let (w, h) = crate::chrome_ui::crop_ratio(key, doc_w, doc_h).filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0)?;
Some(format!("{}:{}", crate::widgets::fmt_num(h), crate::widgets::fmt_num(w)))
Some(format!("{}:{}", crate::crop_size::fmt_ratio(h), crate::crop_size::fmt_ratio(w)))
}
/// X: swaps the pending frame between portrait and landscape about its centre, keeping its angle,
@@ -1949,6 +1949,7 @@ mod tests {
app.ui.tool_options.crop_ratio = String::new();
assert!(swap_orientation(&mut app));
assert_eq!(app.ui.tool_options.crop_ratio, "", "no ratio stays none");
assert_eq!(swapped_ratio("1.414:1", 1.0, 1.0).as_deref(), Some("1:1.414"), "decimals survive the swap (#6487)");
assert_eq!(swapped_ratio("0:5", 1.0, 1.0), None);
assert_eq!(swapped_ratio("nonsense", 1.0, 1.0), None);
// No frame, or another tool: X is Switch Foreground and Background Colors.
+3
View File
@@ -334,6 +334,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
DialogKind::Command if crate::color_picker_ui::owns(&fields) => {
outcome = crate::color_picker_ui::body(ui, &mut fields);
crate::color_picker_ui::take_add_swatch(app, &mut fields);
if let Some(web) = fields.get("__webOnly").and_then(Value::as_bool) {
app.ui.color_panel.web_only = web;
}
}
DialogKind::Command if crate::color_range_ui::owns(&fields) => crate::color_range_ui::body(app, ui, &mut fields),
DialogKind::Command if crate::prefs_ui::owns(&fields) => crate::prefs_ui::body(app, ui, &mut fields),
+57 -17
View File
@@ -21,6 +21,8 @@ pub struct Prompt {
/// The document the command was aimed at (`document` param, else the active one), if it has one.
target: Option<DocId>,
docs: Vec<DocId>,
/// The document whose question has already put keyboard focus on its default answer.
focused: Option<DocId>,
}
fn index_of(app: &PhotocraftApp, id: DocId) -> Option<usize> {
@@ -69,7 +71,7 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> bool {
if docs.is_empty() {
return false;
}
let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs };
let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs, focused: None };
match &app.discard {
None => ask(app, prompt),
// Quitting overrides whatever is pending: it covers every document, so nothing is lost.
@@ -226,6 +228,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Save can ask for a path and then TIFF or Targa Options, and then write in the background.
// Wait for all three; cancellation or a failed save brings us back.
if app.file_dialog_open() || app.save_options_open() || app.saving() {
// Hidden meanwhile: the question takes focus again when it comes back.
if let Some(p) = app.discard.as_mut() {
p.focused = None;
}
return;
}
let Some(p) = &app.discard else { return };
@@ -246,6 +252,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
};
(tl!("Unsaved changes"), crate::i18n::fmt(template, &[("name", &name)]))
};
// Each question comes up with its default answer focused, as in Photoshop: the arrow keys then
// move between the answers and Enter presses the focused one (#6441).
let focus = p.focused != Some(doc);
if let Some(p) = app.discard.as_mut() {
p.focused = Some(doc);
}
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
// The answers in the platform's words; `dialog_buttons` puts them in its order. Windows (and
// Linux) ask Yes / No / Cancel with Y, N and Esc, as Photoshop does there; macOS asks
@@ -266,7 +278,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
};
let mut answer = ctx.input_mut(|i| buttons.iter().find(|b| b.2.is_some_and(|k| i.consume_key(egui::Modifiers::NONE, k))).map(|b| b.4));
let labels = button_labels(mac, &buttons.iter().map(|b| (b.1, b.2)).collect::<Vec<_>>());
let row: Vec<DialogButton> = buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3)).collect();
let row: Vec<DialogButton> =
buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3).focus(focus && b.0 == ButtonRole::Default)).collect();
let modal = egui::Modal::new(egui::Id::new("discard-prompt")).show(ctx, |ui| {
ui.set_max_width(420.0);
ui.label(egui::RichText::new(tl!(&title)).font(crate::theme::semibold(15.0)));
@@ -582,31 +595,60 @@ mod tests {
v.into_iter().map(|(_, l)| l).collect()
}
/// Tab walks `labels` left to right and wraps; Shift+Tab steps back.
fn tab_walks(h: &mut Prompted, labels: [&str; 3]) {
use egui_kittest::kittest::Queryable;
let focused = |h: &Prompted| labels.into_iter().find(|l| h.get_by_label(l).is_focused());
for want in [labels[0], labels[1], labels[2], labels[0]] {
/// Tab walks `labels` left to right from the focused default answer, `labels[default]`, and
/// wraps; Shift+Tab steps back.
fn tab_walks(h: &mut Prompted, labels: [&str; 3], default: usize) {
assert_eq!(focused(h, &labels), Some(labels[default]));
for step in 1..=3 {
h.key_press(Key::Tab);
h.run_steps(2);
assert_eq!(focused(h), Some(want));
assert_eq!(focused(h, &labels), Some(labels[(default + step) % 3]));
}
h.key_press_modifiers(egui::Modifiers::SHIFT, Key::Tab);
h.run_steps(2);
assert_eq!(focused(h), Some(labels[2]));
assert_eq!(focused(h, &labels), Some(labels[(default + 2) % 3]));
}
fn docs_left(h: &Prompted) -> Option<usize> {
h.state().discard.as_ref().map(|p| p.docs.len())
}
/// Which of `labels` has keyboard focus.
fn focused<'a>(h: &Prompted, labels: &[&'a str]) -> Option<&'a str> {
use egui_kittest::kittest::Queryable;
labels.iter().copied().find(|l| h.get_by_label(l).is_focused())
}
/// The default answer has keyboard focus as each question comes up, so the arrow keys move
/// between the answers and Enter presses the one they reached, as in Photoshop (#6441).
#[test]
fn each_question_comes_up_with_the_default_answer_focused() {
let mut h = prompt_on(egui::os::OperatingSystem::Windows);
let labels = ["(Y)es", "(N)o", "(C)ancel"];
assert_eq!(focused(&h, &labels), Some("(Y)es"));
h.key_press(Key::ArrowRight);
h.run_steps(2);
assert_eq!(focused(&h, &labels), Some("(N)o"));
h.key_press(Key::Enter);
h.run_steps(2);
assert_eq!(docs_left(&h), Some(1), "Enter pressed No");
assert_eq!(focused(&h, &labels), Some("(Y)es"), "the next question starts on the default again");
let mut h = prompt_on(egui::os::OperatingSystem::Mac);
let labels = ["Don't Save", "Cancel", "Save"];
assert_eq!(focused(&h, &labels), Some("Save"));
h.key_press(Key::ArrowLeft);
h.run_steps(2);
assert_eq!(focused(&h, &labels), Some("Cancel"));
}
#[test]
fn windows_and_linux_ask_yes_no_cancel_with_the_default_first() {
for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
let mut h = prompt_on(os);
let labels = ["(Y)es", "(N)o", "(C)ancel"];
assert_eq!(drawn_order(&h, labels), labels, "{os:?}");
tab_walks(&mut h, labels);
tab_walks(&mut h, labels, 0);
h.key_press(Key::N);
h.run_steps(2);
assert_eq!(docs_left(&h), Some(1), "N answered the first document");
@@ -699,7 +741,7 @@ mod tests {
let mut h = prompt_on(egui::os::OperatingSystem::Mac);
let labels = ["Don't Save", "Cancel", "Save"];
assert_eq!(drawn_order(&h, labels), labels);
tab_walks(&mut h, labels);
tab_walks(&mut h, labels, 2);
h.key_press(Key::D);
h.run_steps(2);
assert_eq!(docs_left(&h), Some(1), "D answered the first document");
@@ -736,11 +778,9 @@ mod tests {
h.run_steps(2);
assert_eq!(asked.borrow().len(), 1, "Enter answered Yes, which saves");
assert_eq!(docs_left(&h), Some(2));
// Tab to No: Enter now presses it.
for _ in 0..2 {
h.key_press(Key::Tab);
h.run_steps(2);
}
// Tab from the focused Yes to No: Enter now presses it.
h.key_press(Key::Tab);
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(2);
assert_eq!(asked.borrow().len(), 1, "no second dialog");
@@ -776,7 +816,7 @@ mod tests {
assert!(h.query_by_label("(Y)es").is_none(), "the prompt waits behind the dialog");
answer(&mut h, None);
assert_eq!(docs_left(&h), Some(1));
assert!(h.query_by_label("(Y)es").is_some());
assert_eq!(focused(&h, &["(Y)es", "(N)o", "(C)ancel"]), Some("(Y)es"), "the prompt comes back with Yes focused");
// Saving closes the document.
h.key_press(Key::Y);
h.run_steps(3);
@@ -7,6 +7,7 @@ use std::{cell::RefCell, rc::Rc};
type Writes = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
fn tiff_prompt(command: &str) -> (Prompted, Writes, DocId, DocId) {
let _gpu = photocraft_testkit::gpu_lock();
let mut app = app_with_docs(2);
make_dirty(&mut app, 0);
let (saved, other) = (doc_id(&app, 0), doc_id(&app, 1));
+15 -2
View File
@@ -262,8 +262,21 @@ impl PhotocraftApp {
match e {
OsEvent::Open(paths) => {
self.open_paths(&paths);
// Bring the window forward when another app (Finder, the Dock) asked us to open.
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
// Bring the window forward when another app (Finder, the Dock, Explorer via a
// second launch) asked us to open; restore it first if it was minimized.
if ctx.input(|i| i.viewport().minimized) == Some(true) {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
}
// Native Wayland lets a window come forward only with the launcher's activation
// token, which winit can't apply to an existing window (its focus is a no-op
// there): mark the window as wanting attention instead, so the taskbar
// highlights it rather than the file opening out of sight.
let raise = if self.services.is_wayland {
egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational)
} else {
egui::ViewportCommand::Focus
};
ctx.send_viewport_cmd(raise);
ctx.request_repaint();
}
OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
+22
View File
@@ -317,6 +317,28 @@ fn os_open_events_open_files_with_paths() {
let _ = std::fs::remove_dir_all(&dir);
}
/// The viewport commands an `Open` event sends: focus, or an attention request on native Wayland
/// where winit can't focus (#3708).
fn raise_commands(is_wayland: bool) -> Vec<egui::ViewportCommand> {
let (mut app, _) = app_with(Vec::new());
app.services.is_wayland = is_wayland;
let mut queue = vec![OsEvent::Open(Vec::new())];
app.services.os_events = Some(Box::new(move || std::mem::take(&mut queue)));
let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| app.drain_os_events(ui.ctx()));
out.textures_delta.clear();
out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()
}
#[test]
fn an_os_open_brings_the_window_forward_or_asks_for_attention_on_wayland() {
use egui::{UserAttentionType, ViewportCommand};
let attention = ViewportCommand::RequestUserAttention(UserAttentionType::Informational);
let x11 = raise_commands(false);
assert!(x11.contains(&ViewportCommand::Focus) && !x11.contains(&attention), "{x11:?}");
let wayland = raise_commands(true);
assert!(wayland.contains(&attention) && !wayland.contains(&ViewportCommand::Focus), "{wayland:?}");
}
#[test]
fn os_quit_and_empty_events_do_not_panic() {
let (mut app, _) = app_with(Vec::new());
+265 -39
View File
@@ -27,6 +27,7 @@
use std::collections::HashMap;
use std::num::NonZeroU64;
use std::sync::atomic::Ordering;
use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, RenderState, ScreenDescriptor};
use eframe::wgpu;
@@ -110,6 +111,11 @@ pub struct GpuCanvas {
tile: u32,
high: HighPolicy,
health: photocraft_gpu::DeviceHealth,
/// [`video_memory`] of the adapter, detected once.
vram: Option<u64>,
/// [`canvas_budget`], shared with the paint callback's resources: [`GpuCanvas::format_for`]
/// reads it while the renderer is locked.
canvas_budget: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
/// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on
@@ -158,9 +164,16 @@ impl GpuCanvas {
res.health = health.clone();
let info = rs.adapter.get_info();
res.separate_mip_targets = separate_mip_targets(info.backend, info.vendor, std::env::var("PHOTOCRAFT_SEPARATE_MIPS").ok().as_deref());
let vram = video_memory(&info);
res.canvas_budget.store(canvas_budget(physical_memory(), vram), Ordering::Relaxed);
let budget = res.canvas_budget.clone();
rs.renderer.write().callback_resources.insert(res);
log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}", rs.target_format);
Self { rs: rs.clone(), tile, high, health }
log::info!(
"gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}, video memory {}",
rs.target_format,
vram.map_or_else(|| "unknown".to_owned(), |b| format!("{} MB", b >> 20))
);
Self { rs: rs.clone(), tile, high, health, vram, canvas_budget: budget }
}
/// The device's health flag (shared with the wgpu compositor and the paint callback).
@@ -192,6 +205,12 @@ impl GpuCanvas {
self.rs.adapter.get_info()
}
/// The adapter's own memory where it has one and the platform reports it (see
/// [`video_memory`]); it caps [`memory_budget`] and [`canvas_budget`].
pub fn video_memory(&self) -> Option<u64> {
self.vram
}
/// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit
/// documents when the adapter supports it and the document fits [`F16_BUDGET_PX`], else
/// `Rgba8Unorm`.
@@ -204,7 +223,11 @@ impl GpuCanvas {
let px = size[0] as u64 * size[1] as u64;
let high = match self.high {
HighPolicy::Off => false,
HighPolicy::Budget => px <= F16_BUDGET_PX,
// Within the canvas budget too: a 16-bit canvas a small card can't hold as floats
// stays on the GPU as `Rgba8Unorm` rather than falling back to the CPU canvas.
HighPolicy::Budget => {
px <= F16_BUDGET_PX && canvas_texture_bytes(size, self.tile, texel_bytes(FORMAT_HIGH)) <= self.canvas_budget.load(Ordering::Relaxed)
}
HighPolicy::Always => true,
};
if high && depth != photocraft_doc::SampleType::U8 { FORMAT_HIGH } else { FORMAT }
@@ -240,10 +263,7 @@ impl GpuCanvas {
/// Set the GPU bytes one document's display texture set may take (see [`canvas_budget`]).
pub fn set_canvas_budget(&self, bytes: u64) {
let mut r = self.rs.renderer.write();
if let Some(res) = r.callback_resources.get_mut::<Resources>() {
res.canvas_budget = bytes;
}
self.canvas_budget.store(bytes, Ordering::Relaxed);
}
/// Whether `doc`'s display textures fit [`canvas_budget`]; when they don't, nothing is
@@ -252,7 +272,7 @@ impl GpuCanvas {
let size = [doc.size.width, doc.size.height];
let bytes = canvas_texture_bytes(size, self.tile, texel_bytes(self.format_for(doc.depth, size)));
let r = self.rs.renderer.read();
r.callback_resources.get::<Resources>().is_some_and(|res| bytes <= res.canvas_budget)
r.callback_resources.get::<Resources>().is_some_and(|res| bytes <= res.canvas_budget.load(Ordering::Relaxed))
}
/// Cap lower-stack composite reuse; zero disables it for profiling the uncached path.
@@ -283,6 +303,28 @@ impl GpuCanvas {
Some((c.resident_bytes(), c.fx_cache_bytes().saturating_add(usize::try_from(c.composite_cache_bytes()).unwrap_or(usize::MAX))))
}
/// Whether `doc`'s layers fit the budget as it now stands, measured without a refresh (a GPU
/// Memory change shows in the status bar at once). `None` before the compositor exists.
pub fn measure_fit(&self, doc: &photocraft_doc::Document) -> Option<LayerFit> {
let mut r = self.rs.renderer.write();
let res = r.callback_resources.get_mut::<Resources>()?;
let budget = res.compositor_budget;
let comp = res.compositor.as_mut()?;
if let Some(b) = budget {
comp.set_memory_budget(b);
}
let fit = LayerFit { doc: doc.id.0, needed: comp.working_set(doc, doc.bounds()), budget: comp.resident_budget() };
res.layer_fit = Some(fit);
Some(fit)
}
/// Whether the layers of the last full (or large) refresh fit the GPU memory budget, and by
/// how much (`None` before the first one).
pub fn layer_fit(&self) -> Option<LayerFit> {
let r = self.rs.renderer.read();
r.callback_resources.get::<Resources>()?.layer_fit
}
/// Set the checkerboard and gamut warning colours.
pub fn set_style(&self, style: CanvasStyle) {
let mut r = self.rs.renderer.write();
@@ -451,9 +493,13 @@ impl GpuCanvas {
// compositor does it (in the background on desktop). So does a large damage rect, such
// as hiding a big layer (#4024); small edits in the view stay on the GPU.
let large = region.width() as u64 * region.height() as u64 >= DEFER_PX;
if (region == doc.bounds() || large) && !comp.fits_budget(doc, region) {
res.compositor = Some(comp);
return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into()));
if region == doc.bounds() || large {
let fit = LayerFit { doc: doc.id.0, needed: comp.working_set(doc, region), budget: comp.resident_budget() };
res.layer_fit = Some(fit);
if !fit.fits() {
res.compositor = Some(comp);
return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into()));
}
}
if fresh {
let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else {
@@ -906,9 +952,13 @@ pub struct Perf {
/// GPU memory the compositor may hold for layer pages and effect maps (MB; see
/// [`memory_budget`]).
pub gpu_budget_mb: u64,
/// The Memory Usage allowance (bytes) `gpu_budget_mb` was computed from.
/// The Memory Usage allowance and the GPU Memory preference (bytes) `gpu_budget_mb` was
/// computed from.
#[serde(skip)]
pub gpu_budget_allowance: u64,
pub gpu_budget_allowance: (u64, u64),
/// The layer pages the last full refresh needed against the part of the budget they may
/// hold; when they don't fit, edits that redraw the whole document do it on the CPU.
pub gpu_layer_fit: Option<LayerFit>,
/// Last command run through the app and its duration (ms).
pub last_command: String,
pub command_ms: f64,
@@ -934,6 +984,9 @@ pub struct GpuInfo {
pub device_type: String,
/// Driver name and version as the driver reports them.
pub driver: String,
/// The adapter's own memory in MB ([`video_memory`]): discrete GPUs where the platform
/// reports it. It caps the GPU memory budgets.
pub video_memory_mb: Option<u64>,
/// `performance.gpuBackend` at launch.
pub preference: String,
/// The backend startup chose ("auto", "dx12", "cpu", …, or "env" for `WGPU_BACKEND`).
@@ -945,6 +998,9 @@ pub struct GpuInfo {
pub canvas: String,
/// Why the GPU canvas was dropped during this session (device lost or a GPU error).
pub lost: Option<String>,
/// Present mode and frame latency ("fifo, latency 2"; launch overrides marked, see the
/// app's `presentation`). Empty in hosts that don't set it.
pub present: String,
}
impl GpuInfo {
@@ -961,6 +1017,7 @@ impl GpuInfo {
}
.to_string();
self.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ");
self.video_memory_mb = video_memory(info).map(|b| b >> 20);
}
/// Human-readable lines for Help › System Info.
@@ -1185,21 +1242,53 @@ fn texel_to_f32(format: wgpu::TextureFormat, b: &[u8]) -> [f32; 4] {
// ---------------------------------------------------------------------------------------------
// GPU resources
/// The GPU memory a refresh's layer pages need against what they may hold (bytes).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LayerFit {
/// The document refreshed.
pub doc: u64,
pub needed: u64,
pub budget: u64,
}
impl LayerFit {
pub fn fits(&self) -> bool {
self.needed <= self.budget
}
}
/// Floor of the compositor's memory budget: enough for a viewport's pages of a dozen layers.
pub const MIN_GPU_BUDGET: u64 = 512 << 20;
/// Why a full refresh whose layer pages don't fit [`memory_budget`] goes to the CPU compositor.
pub const OVER_BUDGET: &str = "layers exceed the GPU memory budget; full refresh on the CPU";
/// GPU memory the wgpu compositor may hold for layer pages and effect maps: what Memory Usage
/// (`allowance`, Preferences › Performance) leaves after the document's pixels and History
/// (`pixels`), at most a quarter of physical memory (`ram`; 16 GB assumed when unknown), and at
/// least [`MIN_GPU_BUDGET`]. On unified-memory machines these textures share RAM with the
/// document, so a huge document keeps its layers on the GPU only as far as memory allows; the
/// rest is uploaded per refresh as the view needs it.
pub fn memory_budget(allowance: u64, pixels: u64, ram: Option<u64>) -> u64 {
/// GPU memory the wgpu compositor may hold for layer pages and effect maps.
///
/// `fixed` (Preferences › Performance › GPU Memory, bytes) sets it when it isn't 0, within
/// [`vram_share`] of a discrete GPU's own memory (`vram`, from [`video_memory`]). Otherwise it is
/// Memory Usage (`allowance`) within a quarter of physical memory (`ram`; 16 GB assumed when
/// unknown) and the VRAM share. The document's pixels and History (`pixels`) come off Memory Usage
/// unless the card's own memory is known: a discrete card doesn't hold them, while integrated and
/// software adapters (and a card whose memory can't be read) share the system's. Never below
/// [`MIN_GPU_BUDGET`]. Layers beyond it redraw full refreshes on the CPU ([`OVER_BUDGET`]).
pub fn memory_budget(allowance: u64, pixels: u64, ram: Option<u64>, vram: Option<u64>, fixed: u64) -> u64 {
let card = vram.map_or(u64::MAX, vram_share);
if fixed > 0 {
return fixed.min(card).max(MIN_GPU_BUDGET);
}
let ram = ram.unwrap_or(16 << 30);
allowance.saturating_sub(pixels).min(ram / 4).max(MIN_GPU_BUDGET)
let charged = if vram.is_some() { 0 } else { pixels };
allowance.saturating_sub(charged).min(ram / 4).min(card).max(MIN_GPU_BUDGET)
}
/// The share of a discrete GPU's memory each of [`memory_budget`] and [`canvas_budget`] may
/// plan for: three eighths, so the two together leave a quarter of the card to the window's
/// swapchain, egui and other applications instead of over-committing it and letting the driver
/// page (#4536).
pub fn vram_share(vram: u64) -> u64 {
vram / 8 * 3
}
/// Why a document whose display textures don't fit [`canvas_budget`] is drawn on the CPU canvas.
@@ -1217,11 +1306,12 @@ pub const REFRESH_DEFERRED: &str = "deferred";
pub const DEFER_PX: u64 = 1 << 22;
/// GPU bytes one document's display texture set (every tile and mip level) may take: a quarter
/// of physical memory (`ram`; 16 GB assumed when unknown), at least [`MIN_GPU_BUDGET`]. The set
/// covers the whole canvas at full resolution, so its size follows the declared canvas, not the
/// layer pixels; a larger canvas is drawn on the (reduced) CPU canvas instead (#2934).
pub fn canvas_budget(ram: Option<u64>) -> u64 {
(ram.unwrap_or(16 << 30) / 4).max(MIN_GPU_BUDGET)
/// of physical memory (`ram`; 16 GB assumed when unknown) and at most [`vram_share`] of a
/// discrete GPU's own memory (`vram`), at least [`MIN_GPU_BUDGET`]. The set covers the whole
/// canvas at full resolution, so its size follows the declared canvas, not the layer pixels; a
/// larger canvas is drawn on the (reduced) CPU canvas instead (#2934).
pub fn canvas_budget(ram: Option<u64>, vram: Option<u64>) -> u64 {
(ram.unwrap_or(16 << 30) / 4).min(vram.map_or(u64::MAX, vram_share)).max(MIN_GPU_BUDGET)
}
/// GPU bytes of a display texture set for a `size` canvas in `tile`-sided tiles of `bpp` bytes
@@ -1285,6 +1375,70 @@ fn detect_physical_memory() -> Option<u64> {
None
}
/// The memory of `adapter` itself, in bytes, when it is a discrete GPU and the platform reports
/// it: DXGI's dedicated video memory on Windows, amdgpu's VRAM size on Linux. `None` for
/// integrated and software adapters, whose textures live in system memory (the RAM-based
/// budgets already cover them), and where nothing reports it. `PHOTOCRAFT_VRAM_MB` overrides it
/// for any adapter, simulating a discrete card of that size: on an integrated GPU the budget then
/// stops charging the document's pixels too, as for a real card (a development switch).
pub fn video_memory(adapter: &wgpu::AdapterInfo) -> Option<u64> {
if let Some(mb) = std::env::var("PHOTOCRAFT_VRAM_MB").ok().and_then(|v| v.trim().parse::<u64>().ok()) {
return mb.checked_mul(1 << 20).filter(|b| *b > 0);
}
if adapter.device_type != wgpu::DeviceType::DiscreteGpu {
return None;
}
// Detected once per adapter, as `physical_memory` is: on Windows each detection builds a
// DXGI factory, and startup asks three times. Keyed by the adapter, since a GPU retry can
// pick another one.
/// (vendor, device) ids and the memory found for them.
type Seen = Vec<((u32, u32), Option<u64>)>;
static SEEN: std::sync::Mutex<Seen> = std::sync::Mutex::new(Vec::new());
let key = (adapter.vendor, adapter.device);
let mut seen = SEEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((_, bytes)) = seen.iter().find(|(k, _)| *k == key) {
return *bytes;
}
let bytes = detect_video_memory(key.0, key.1).filter(|b| *b > 0);
seen.push((key, bytes));
bytes
}
#[cfg(windows)]
fn detect_video_memory(vendor: u32, device: u32) -> Option<u64> {
use winsafe::prelude::*;
let factory = winsafe::CreateDXGIFactory1().ok()?;
factory
.EnumAdapters1()
.filter_map(|a| a.ok()?.GetDesc1().ok())
.find(|d| d.VendorId == vendor && d.DeviceId == device)
.and_then(|d| u64::try_from(d.DedicatedVideoMemory).ok())
}
#[cfg(target_os = "linux")]
fn detect_video_memory(vendor: u32, device: u32) -> Option<u64> {
drm_video_memory(std::path::Path::new("/sys/class/drm"), vendor, device)
}
/// amdgpu's VRAM size (`<card>/device/mem_info_vram_total`, bytes) of the card under `drm` (the
/// sysfs `/sys/class/drm` folder) whose PCI `vendor` and `device` ids (hex files beside it) match.
#[cfg(any(target_os = "linux", test))]
fn drm_video_memory(drm: &std::path::Path, vendor: u32, device: u32) -> Option<u64> {
let hex = |p: std::path::PathBuf| u32::from_str_radix(std::fs::read_to_string(p).ok()?.trim().trim_start_matches("0x"), 16).ok();
std::fs::read_dir(drm).ok()?.flatten().find_map(|card| {
let dev = card.path().join("device");
if hex(dev.join("vendor"))? != vendor || hex(dev.join("device"))? != device {
return None;
}
std::fs::read_to_string(dev.join("mem_info_vram_total")).ok()?.trim().parse().ok()
})
}
#[cfg(not(any(target_os = "linux", windows)))]
fn detect_video_memory(_vendor: u32, _device: u32) -> Option<u64> {
None
}
struct Resources {
view_bgl: wgpu::BindGroupLayout,
tile_bgl: wgpu::BindGroupLayout,
@@ -1312,10 +1466,13 @@ struct Resources {
/// GPU memory the compositor may hold (`None`: its default), and the document area the
/// view shows; applied before every composite.
compositor_budget: Option<u64>,
/// The layer pages the last full (or large) refresh needed and the budget they had to fit
/// (bytes): when they don't fit, full refreshes go to the CPU ([`OVER_BUDGET`]).
layer_fit: Option<LayerFit>,
compositor_cache_budget: Option<u64>,
compositor_focus: Option<photocraft_geom::Rect>,
/// GPU bytes one document's display texture set may take (see [`canvas_budget`]).
canvas_budget: u64,
canvas_budget: std::sync::Arc<std::sync::atomic::AtomicU64>,
encode_bgl: wgpu::BindGroupLayout,
encode_pipeline: wgpu::RenderPipeline,
lut_bgl: wgpu::BindGroupLayout,
@@ -1579,9 +1736,10 @@ impl Resources {
compositor_failed: None,
health: photocraft_gpu::DeviceHealth::new(),
compositor_budget: None,
layer_fit: None,
compositor_cache_budget: None,
compositor_focus: None,
canvas_budget: canvas_budget(physical_memory()),
canvas_budget: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(canvas_budget(physical_memory(), None))),
encode_bgl,
encode_pipeline,
}
@@ -1620,7 +1778,7 @@ impl DocTextures {
/// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit
/// the canvas budget.
fn new(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option<Self> {
if canvas_texture_bytes(size, tile, texel_bytes(format)) > res.canvas_budget {
if canvas_texture_bytes(size, tile, texel_bytes(format)) > res.canvas_budget.load(Ordering::Relaxed) {
return None;
}
let mut tiles = Vec::new();
@@ -2237,16 +2395,84 @@ mod tests {
#[test]
fn gpu_budget_follows_memory_usage_and_ram() {
const G: u64 = 1 << 30;
// What Memory Usage leaves after the document, within a quarter of RAM.
assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G)), 5 * G);
assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G)), 4 * G);
assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G)), 4 * G);
// Card memory unknown (integrated, software, or unreadable): what Memory Usage leaves
// after the document, within a quarter of RAM.
assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), None, 0), 5 * G);
assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G), None, 0), 4 * G);
assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G), None, 0), 4 * G);
// Never below the floor, even with nothing left; 16 GB assumed when RAM is unknown.
assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G)), MIN_GPU_BUDGET);
assert_eq!(memory_budget(64 * G, 0, None), 4 * G);
assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G), None, 0), MIN_GPU_BUDGET);
assert_eq!(memory_budget(64 * G, 0, None, None, 0), 4 * G);
// A card's own memory, known, doesn't hold the document's pixels: 10 GB of layers no
// longer squeeze the budget, which stays within 3/8 of the card (#4536).
assert_eq!(memory_budget(8 * G, 10 * G, Some(32 * G), Some(32 * G), 0), 8 * G);
assert_eq!(memory_budget(8 * G, 10 * G, Some(32 * G), Some(16 * G), 0), 6 * G);
assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), Some(32 * G), 0), 4 * G);
// GPU Memory set: that much, within the card's share, above the floor.
assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), None, 12 * G), 12 * G);
assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), Some(16 * G), 12 * G), 6 * G);
assert_eq!(memory_budget(8 * G, 0, None, Some(32 * G), 1 << 20), MIN_GPU_BUDGET);
assert!(physical_memory().is_none_or(|b| b >= 256 << 20));
}
/// An 8 GB card in a 64 GB machine: both budgets used to plan for 16 GB, twice the card (#4536).
#[test]
fn gpu_budgets_fit_a_discrete_gpus_own_memory() {
const G: u64 = 1 << 30;
let (ram, vram) = (Some(64 * G), Some(8 * G));
assert_eq!(memory_budget(64 * G, 3 * G, ram, vram, 0), 3 * G);
assert_eq!(canvas_budget(ram, vram), 3 * G);
assert!(memory_budget(64 * G, 0, ram, vram, 0) + canvas_budget(ram, vram) <= 6 * G, "a quarter of the card stays free");
// A card larger than a quarter of RAM caps nothing (its own memory isn't charged for the
// document's 3 GB); a tiny one keeps the floor.
assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), Some(24 * G), 0), 8 * G);
assert_eq!(canvas_budget(Some(16 * G), Some(24 * G)), 4 * G);
assert_eq!(memory_budget(64 * G, 0, ram, Some(G), 0), MIN_GPU_BUDGET);
assert_eq!(canvas_budget(ram, Some(G)), MIN_GPU_BUDGET);
}
/// DXGI reports each adapter's dedicated memory, found by the vendor and device ids wgpu gives.
#[cfg(windows)]
#[test]
fn windows_reports_the_adapters_own_memory() {
use winsafe::prelude::*;
let descs: Vec<_> = winsafe::CreateDXGIFactory1().unwrap().EnumAdapters1().map(|a| a.unwrap().GetDesc1().unwrap()).collect();
assert!(!descs.is_empty(), "DXGI lists at least its software adapter");
for d in descs.iter().filter(|d| d.DedicatedVideoMemory > 0) {
// The first adapter with these ids is the one matched.
let first = descs.iter().find(|e| e.VendorId == d.VendorId && e.DeviceId == d.DeviceId).unwrap();
assert_eq!(detect_video_memory(d.VendorId, d.DeviceId), Some(first.DedicatedVideoMemory as u64));
}
assert_eq!(detect_video_memory(0xdead, 0xbeef), None, "no such adapter");
}
/// The Linux reader finds the card by its PCI ids in a `/sys/class/drm`-shaped folder and reads
/// amdgpu's VRAM size; a card without one, unreadable ids or a malformed size give nothing.
#[test]
fn drm_video_memory_reads_the_matching_cards_vram() {
let drm = std::env::temp_dir().join(format!("pc-drm-{}", std::process::id()));
let card = |name: &str, vendor: &str, device: &str, vram: Option<&str>| {
let dev = drm.join(name).join("device");
std::fs::create_dir_all(&dev).unwrap();
std::fs::write(dev.join("vendor"), vendor).unwrap();
std::fs::write(dev.join("device"), device).unwrap();
if let Some(v) = vram {
std::fs::write(dev.join("mem_info_vram_total"), v).unwrap();
}
};
// An Intel iGPU (no VRAM file), an AMD card, and an entry with garbage ids.
card("card0", "0x8086\n", "0x9a49\n", None);
card("card1", "0x1002\n", "0x73bf\n", Some("17163091968\n"));
card("card2", "not hex\n", "0x73bf\n", Some("1\n"));
card("card3", "0x10de\n", "0x2684\n", Some("lots\n"));
assert_eq!(drm_video_memory(&drm, 0x1002, 0x73bf), Some(17_163_091_968));
assert_eq!(drm_video_memory(&drm, 0x8086, 0x9a49), None, "no amdgpu VRAM file");
assert_eq!(drm_video_memory(&drm, 0x10de, 0x2684), None, "a malformed size");
assert_eq!(drm_video_memory(&drm, 0xdead, 0xbeef), None, "no such card");
assert_eq!(drm_video_memory(&drm.join("missing"), 0x1002, 0x73bf), None, "no drm folder");
std::fs::remove_dir_all(&drm).unwrap();
}
/// Windows used to report no RAM at all, so the budget assumed 16 GB on every machine.
#[cfg(windows)]
#[test]
@@ -2277,12 +2503,12 @@ mod tests {
assert_eq!(canvas_texture_bytes([0, 500], 64, 4), 0);
// A canvas declared in a few bytes of a file is priced without building (or looping over) it.
let huge = canvas_texture_bytes([300_000, 300_000], 8192, 4);
assert!(huge > 300_000u64 * 300_000 * 4 && huge > canvas_budget(Some(1 << 40)));
assert!(huge > 300_000u64 * 300_000 * 4 && huge > canvas_budget(Some(1 << 40), None));
assert_eq!(canvas_texture_bytes([u32::MAX, u32::MAX], 64, 8), u64::MAX, "saturates");
// A quarter of RAM (16 GB assumed), never below the compositor's floor.
assert_eq!(canvas_budget(None), 4 << 30);
assert_eq!(canvas_budget(Some(1 << 30)), MIN_GPU_BUDGET);
assert!(canvas_texture_bytes([12_000, 8_000], DEFAULT_TILE, 8) < canvas_budget(None), "a 96 MP 16-bit canvas stays on the GPU");
assert_eq!(canvas_budget(None, None), 4 << 30);
assert_eq!(canvas_budget(Some(1 << 30), None), MIN_GPU_BUDGET);
assert!(canvas_texture_bytes([12_000, 8_000], DEFAULT_TILE, 8) < canvas_budget(None, None), "a 96 MP 16-bit canvas stays on the GPU");
}
#[test]
@@ -51,6 +51,7 @@ fn read_mips(gpu: &GpuCanvas, key: u64) -> Vec<Vec<[f32; 4]>> {
#[test]
fn canvas_mips_preserve_opaque_pixels_and_partial_updates() {
let _gpu = photocraft_testkit::gpu_lock();
let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(wgpu_setup(), Default::default())));
let Ok(rs) = rs else {
eprintln!("skipping mip readback: no GPU adapter");
+2
View File
@@ -50,6 +50,8 @@ pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) {
app.prefs_rt.gpu_style = None;
app.perf.gpu = false;
app.perf.gpu_fallback = Some(fault.to_string());
// The CPU canvas has no GPU memory budget: GPU Memory Full no longer applies.
app.perf.gpu_layer_fit = None;
app.perf.gpu_info.canvas = "cpu".into();
app.perf.gpu_info.lost = Some(fault.to_string());
let title = if fault.is_lost() { LOST_MESSAGE } else { ERROR_MESSAGE };
+13 -1
View File
@@ -2442,7 +2442,6 @@ cameraRaw Color Barvy
Redrawing Překreslování
This redraw took {seconds} s on the CPU. Toto překreslení na CPU trvalo {seconds} s.
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Úpravy, které mění celý dokument, například režim prolnutí, krytí nebo viditelnost vrstvy, jej překreslí celý.
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Vrstvy se nevejdou do rozpočtu paměti GPU. Zvýšením hodnoty Využití paměti v Předvolbách › Výkon se rozpočet zvětší, nejvýše na čtvrtinu paměti tohoto počítače.
The GPU compositor wasn't used: {reason} Kompozitor GPU nebyl použit: {reason}
fontWeight Thin Jemná
fontWeight ExtraLight Extrajemná
@@ -3804,3 +3803,16 @@ navigator Zoom Zvětšení
Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho
Undo Changes Vrátit změny
{shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit.
Low-Latency Drawing (vsync off, applies at next launch) Kreslení s nízkou latencí (bez vertikální synchronizace, platí po dalším spuštění)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Volná transformace se stále použije; zkuste to znovu, až skončí.
The transform couldn't be applied. Transformaci nelze použít.
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Vrstvy potřebují {needed} paměti GPU; rozpočet jim ponechává {budget}.
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. V Předvolbách › Výkon nastavte Paměť GPU na tolik, kolik vaše grafická karta pojme, nejvýše 3/8 její paměti. Automaticky se řídí Využitím paměti, v rámci čtvrtiny paměti tohoto počítače a 3/8 paměti karty, a u karty se známou pamětí do ní dokument nezapočítává. Dokud se nevejdou, stavový řádek ukazuje Paměť GPU je plná.
GPU Memory Full Paměť GPU je plná
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Vrstvy potřebují {needed} paměti GPU a mohou zabrat {budget}, takže úpravy, které překreslují celý dokument, to dělají na CPU. Kliknutím nastavíte Paměť GPU.
GPU memory budget: {budget} Rozpočet paměti GPU: {budget}
This document's layers: {needed} of {budget} for layers Vrstvy tohoto dokumentu: {needed} z {budget} pro vrstvy
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Vrstvy tohoto dokumentu potřebují {needed}, ale mohou zabrat {budget}: překreslení celého dokumentu probíhá na CPU
GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13 -1
View File
@@ -2481,7 +2481,6 @@ layerLabel No Color Keine Farbe
Redrawing Neuzeichnen
This redraw took {seconds} s on the CPU. Dieses Neuzeichnen auf der CPU dauerte {seconds} s.
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Änderungen, die das ganze Dokument betreffen, etwa Füllmethode, Deckkraft oder Sichtbarkeit einer Ebene, zeichnen es vollständig neu.
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Die Ebenen passen nicht in das GPU-Speicherbudget. Eine höhere Speichernutzung unter Voreinstellungen › Leistung erhöht das Budget, bis zu einem Viertel des Arbeitsspeichers dieses Computers.
The GPU compositor wasn't used: {reason} Der GPU-Compositor wurde nicht verwendet: {reason}
cameraRaw Light Licht
fontWeight Thin Haarfein
@@ -3198,3 +3197,16 @@ curves Smooth Glätten
Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen
Undo Changes Änderungen rückgängig machen
{shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden.
Low-Latency Drawing (vsync off, applies at next launch) Zeichnen mit geringer Latenz (ohne VSync, gilt ab dem nächsten Start)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Frei transformieren wird noch angewendet; versuchen Sie es erneut, wenn es fertig ist.
The transform couldn't be applied. Die Transformation konnte nicht angewendet werden.
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Die Ebenen brauchen {needed} GPU-Speicher; das Budget lässt ihnen {budget}.
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Stellen Sie unter Voreinstellungen › Leistung den GPU-Speicher auf das ein, was Ihre Grafikkarte fassen kann, höchstens 3/8 ihres Speichers. Automatisch folgt der Speichernutzung, innerhalb eines Viertels des Arbeitsspeichers dieses Computers und 3/8 des Kartenspeichers, und rechnet das Dokument bei einer Karte mit bekanntem Speicher nicht an. Solange sie nicht hineinpassen, zeigt die Statusleiste GPU-Speicher voll.
GPU Memory Full GPU-Speicher voll
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Die Ebenen brauchen {needed} GPU-Speicher und dürfen {budget} belegen, daher zeichnen Änderungen, die das ganze Dokument neu zeichnen, auf der CPU. Klicken, um den GPU-Speicher einzustellen.
GPU memory budget: {budget} GPU-Speicherbudget: {budget}
This document's layers: {needed} of {budget} for layers Ebenen dieses Dokuments: {needed} von {budget} für Ebenen
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Die Ebenen dieses Dokuments brauchen {needed}, dürfen aber {budget} belegen: Das ganze Dokument wird auf der CPU neu gezeichnet
GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13 -1
View File
@@ -3439,7 +3439,6 @@ cameraRaw Yellow Κίτρινο
HSB/HSL HSB/HSL
Import Swatches… Εισαγωγή δειγμάτων χρωμάτων…
Impressionist Ιμπρεσιονιστικό
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Τα επίπεδά του δεν χωρούν στο όριο μνήμης της GPU. Αυξάνοντας τη Χρήση μνήμης στις Προτιμήσεις › Επιδόσεις αυξάνεται το όριο, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή.
Knockout Διάτρηση
Large document: redrawing on the CPU Μεγάλο έγγραφο: επανασχεδίαση στη CPU
Redrawing Επανασχεδίαση
@@ -3896,3 +3895,16 @@ curves Smooth Εξομάλυνση
Take the shortcut from the other command and select it Αφαίρεση της συντόμευσης από την άλλη εντολή και επιλογή της
Undo Changes Αναίρεση αλλαγών
{shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί.
Low-Latency Drawing (vsync off, applies at next launch) Σχεδίαση χαμηλής καθυστέρησης (χωρίς vsync, ισχύει στην επόμενη εκκίνηση)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Ο Ελεύθερος μετασχηματισμός εφαρμόζεται ακόμη· δοκιμάστε ξανά όταν ολοκληρωθεί.
The transform couldn't be applied. Δεν ήταν δυνατή η εφαρμογή του μετασχηματισμού.
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Τα επίπεδά του χρειάζονται {needed} μνήμης GPU· το όριο τους αφήνει {budget}.
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ορίστε τη Μνήμη GPU στις Προτιμήσεις › Επιδόσεις σε όσο χωράει η κάρτα γραφικών σας, το πολύ στα 3/8 της μνήμης της. Το Αυτόματο ακολουθεί τη Χρήση μνήμης, εντός του ενός τετάρτου της μνήμης αυτού του υπολογιστή και των 3/8 της κάρτας, και δεν χρεώνει το έγγραφο σε κάρτα με γνωστή μνήμη. Όσο δεν χωρούν, η γραμμή κατάστασης δείχνει Η μνήμη GPU είναι πλήρης.
GPU Memory Full Η μνήμη GPU είναι πλήρης
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Τα επίπεδα χρειάζονται {needed} μνήμης GPU και μπορούν να καταλάβουν {budget}, οπότε οι αλλαγές που ξανασχεδιάζουν όλο το έγγραφο το κάνουν στη CPU. Κάντε κλικ για να ορίσετε τη Μνήμη GPU.
GPU memory budget: {budget} Όριο μνήμης GPU: {budget}
This document's layers: {needed} of {budget} for layers Επίπεδα αυτού του εγγράφου: {needed} από {budget} για επίπεδα
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Τα επίπεδα αυτού του εγγράφου χρειάζονται {needed} αλλά μπορούν να καταλάβουν {budget}: η επανασχεδίαση όλου του εγγράφου γίνεται στη CPU
GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο)
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+13 -1
View File
@@ -2512,7 +2512,6 @@ cameraRaw Amount Cantidad
Redrawing Redibujando
This redraw took {seconds} s on the CPU. Este redibujado en la CPU tardó {seconds} s.
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Las ediciones que cambian todo el documento, como el modo de fusión, la opacidad o la visibilidad de una capa, lo redibujan por completo.
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Las capas no caben en el presupuesto de memoria de la GPU. Aumentar Uso de memoria en Preferencias › Rendimiento amplía el presupuesto, hasta una cuarta parte de la memoria de este equipo.
The GPU compositor wasn't used: {reason} No se usó el compositor de GPU: {reason}
fontWeight Thin Ultrafina
fontWeight ExtraLight Extrafina
@@ -4158,3 +4157,16 @@ navigator Zoom Zoom
Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo
Undo Changes Deshacer cambios
{shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar.
Low-Latency Drawing (vsync off, applies at next launch) Dibujo de baja latencia (sin sincronización vertical, se aplica al volver a iniciar)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. Transformación libre aún se está aplicando; inténtelo de nuevo cuando termine.
The transform couldn't be applied. No se pudo aplicar la transformación.
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Sus capas necesitan {needed} de memoria de GPU; el presupuesto les deja {budget}.
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ajuste Memoria de GPU en Preferencias › Rendimiento a lo que pueda alojar su tarjeta gráfica, como máximo 3/8 de su memoria. Automático sigue Uso de memoria, dentro de una cuarta parte de la memoria de este equipo y 3/8 de la de la tarjeta, y no descuenta el documento en una tarjeta cuya memoria se conoce. Mientras no quepan, la barra de estado muestra Memoria de GPU llena.
GPU Memory Full Memoria de GPU llena
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Las capas necesitan {needed} de memoria de GPU y pueden ocupar {budget}, así que las ediciones que redibujan todo el documento lo hacen en la CPU. Haga clic para ajustar Memoria de GPU.
GPU memory budget: {budget} Presupuesto de memoria de GPU: {budget}
This document's layers: {needed} of {budget} for layers Capas de este documento: {needed} de {budget} para capas
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Las capas de este documento necesitan {needed} pero pueden ocupar {budget}: los redibujados de todo el documento usan la CPU
GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático)
Can't render this file because it contains an unexpected character in line 4 and column 47.
+13 -1
View File
@@ -2862,7 +2862,6 @@ cameraRaw Blending Fusion
Redrawing Redessin
This redraw took {seconds} s on the CPU. Ce redessin sur le CPU a pris {seconds} s.
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Les modifications qui touchent tout le document, comme le mode de fusion, l'opacité ou la visibilité d'un calque, le redessinent entièrement.
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Les calques dépassent le budget de mémoire GPU. Augmenter Utilisation de la mémoire dans Préférences › Performances relève ce budget, jusqu'au quart de la mémoire de cet ordinateur.
The GPU compositor wasn't used: {reason} Le compositeur GPU n'a pas été utilisé : {reason}
fontWeight Thin Ultrafin
fontWeight ExtraLight Extrafin
@@ -3201,3 +3200,16 @@ curves Smooth Lisser
Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner
Undo Changes Annuler les modifications
{shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué.
Low-Latency Drawing (vsync off, applies at next launch) Dessin à faible latence (sans synchronisation verticale, au prochain lancement)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. La transformation manuelle est encore en cours d'application ; réessayez quand elle sera terminée.
The transform couldn't be applied. Impossible d'appliquer la transformation.
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Ses calques ont besoin de {needed} de mémoire GPU ; le budget leur laisse {budget}.
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Réglez Mémoire GPU dans Préférences › Performances sur ce que votre carte graphique peut contenir, au plus 3/8 de sa mémoire. Automatique suit Utilisation de la mémoire, dans la limite du quart de la mémoire de cet ordinateur et des 3/8 de celle de la carte, et ne compte pas le document sur une carte dont la mémoire est connue. Tant qu'ils ne tiennent pas, la barre d'état affiche Mémoire GPU pleine.
GPU Memory Full Mémoire GPU pleine
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Les calques ont besoin de {needed} de mémoire GPU et peuvent en occuper {budget} : les modifications qui redessinent tout le document le font sur le CPU. Cliquez pour régler Mémoire GPU.
GPU memory budget: {budget} Budget de mémoire GPU : {budget}
This document's layers: {needed} of {budget} for layers Calques de ce document : {needed} sur {budget} pour les calques
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Les calques de ce document ont besoin de {needed} mais peuvent occuper {budget} : le document entier se redessine sur le CPU
GPU Memory (MB, 0 = Automatic) Mémoire GPU (Mo, 0 = Automatique)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13 -1
View File
@@ -3312,7 +3312,6 @@ cameraRaw Yellow Sárga
Interpret Footage Videoanyag értelmezése
Interval Intervallum
Invalid {mode} profile Érvénytelen profil ({mode})
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. A dokumentum rétegei nem férnek el a GPU memóriakeretében. Ha a Beállítások › Teljesítmény alatt növeled a memóriahasználatot, a keret is nő, legfeljebb a számítógép memóriájának negyedéig.
Join us on Discord Csatlakozz hozzánk a Discordon
Keep Using CPU Marad a CPU
Kernel Szűrőmag
@@ -4115,3 +4114,16 @@ cameraRaw Yellow Sárga
Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése
Undo Changes Változások visszavonása
{shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá.
Low-Latency Drawing (vsync off, applies at next launch) Kis késleltetésű rajzolás (vsync nélkül, a következő indításkor lép életbe)
# Free Transform: a new layer while the box is open (#5859)
Free Transform is still being applied; try again when it has finished. A szabad alakítás alkalmazása még folyamatban van; próbáld újra, ha befejeződött.
The transform couldn't be applied. Az átalakítás nem alkalmazható.
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
Its layers need {needed} of GPU memory; the budget leaves them {budget}. A rétegeknek {needed} GPU-memória kell; a keret {budget} hagy nekik.
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. A Beállítások › Teljesítmény alatt állítsd a GPU-memóriát arra, amennyit a videokártyád elbír, legfeljebb a memóriája 3/8-ára. Az Automatikus a memóriahasználatot követi, a számítógép memóriájának negyedén és a kártya 3/8-án belül, és ismert memóriájú kártyánál nem számítja bele a dokumentumot. Amíg nem férnek el, az állapotsor a GPU-memória megtelt üzenetet mutatja.
GPU Memory Full GPU-memória megtelt
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. A rétegeknek {needed} GPU-memória kell, és {budget} foglalhatnak, ezért a teljes dokumentumot újrarajzoló szerkesztések a CPU-n futnak. Kattints a GPU-memória beállításához.
GPU memory budget: {budget} GPU-memóriakeret: {budget}
This document's layers: {needed} of {budget} for layers A dokumentum rétegei: {needed} / {budget} a rétegeknek
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU A dokumentum rétegeinek {needed} kell, de {budget} foglalhatnak: a teljes dokumentum újrarajzolása a CPU-n fut
GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus)
Can't render this file because it contains an unexpected character in line 6 and column 47.
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more