Commit Graph
8721 Commits
Author SHA1 Message Date
David MannandClaude Opus 5.5 75a6828f53 Batch: picking the Crop tool again keeps a committed crop's frame hidden
Measured in the reference application: after ↵ commits a crop, the Crop
tool shows no frame; pressing C again, or another tool and back, keeps it
hidden, and only a press on the canvas brings the frame back. A crop
committed by leaving the tool is different: back on the Crop tool the idle
frame shows at once. #10642 brought the frame back on re-picking; picking
the tool no longer ends the hidden state, and a commit on leaving the tool
does not start it. Tests follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 00:13:42 +01:00
David MannandClaude Opus 5.5 37c84c18e5 Batch: Ctrl-held Straighten test expects transparent corners with Delete Cropped Pixels off
Main's #10905 (#10867) turns the Background into Layer 0 when a crop keeps
the cropped pixels, so the corners a turn uncovers stay transparent instead
of taking the background colour. The #10950 test now expects that for its
Delete Cropped Pixels off cases; with it on the corners are still the
background colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:39:48 +01:00
David MannandClaude Opus 5.5 c466cac619 Batch: H/P and Info panel tests pick the Crop tool again after a commit
#10642 hides the frame after a committed crop until the next press or
re-picking the tool. The #9855 and #10453 tests expected the default frame
right after the commit; they now check that it is hidden, pick the tool
again and then test the default frame as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:56:21 +01:00
David Mann e06d1cbdc1 Merge PR #11067: Layers panel: filter by layer effect (#10880) 2026-11-03 22:55:47 +01:00
David Mann 3c9ae63951 Merge PR #10955: Actions: map recorded Shear curves 2026-11-03 22:55:46 +01:00
David Mann d8ea8061ee Merge PR #11109: Crop: turning the image keeps the frame inside it (#10869) 2026-11-03 22:55:46 +01:00
Psychotoxical 66b9f2f68b Merge remote-tracking branch 'origin/main' into feat/layer-filter-effect
# Conflicts:
#	crates/automation/src/server.rs
#	crates/ui-egui/src/panels/layer_filter_ui.rs
#	crates/ui-egui/src/state.rs
#	docs/control-protocol.md
2026-11-03 21:55:06 +01:00
ljkx c15a281045 Merge remote-tracking branch 'origin/main' into crop-turn-keeps-frame-inside 2026-11-03 21:49:55 +01:00
yoriswind 7763e2578f Merge main into recorded Shear mapping 2026-11-03 12:11:27 -08:00
David Mann 576b705a07 Merge PR #10850: fix(algo): Filter Gallery reach no longer overflows on out-of-range stored effect values 2026-11-03 20:47:27 +01:00
David Mann 0d4e565062 Merge PR #9573: fix(ui): Free Transform keeps plain layers over the moving one under a blend or adjustment layer (#9387) 2026-11-03 20:47:24 +01:00
David Mann f199789b00 Merge PR #10453: Crop: the Info panel shows the crop frame's W and H (#3145) 2026-11-03 20:47:20 +01:00
David Mann b91a75185a Merge PR #9855: Crop: H and P pick the Hand and Pen tools over the default frame (#9731) 2026-11-03 20:47:16 +01:00
David Mann 8e29e9d55d Merge PR #11019: Crop: give each overlay its own glyph in the menus and on the options-bar button (#10889) 2026-11-03 20:47:13 +01:00
David Mann 940f3b4715 Merge PR #10565: Free Transform: lift a pixel's coverage up to all of it, as Move does 2026-11-03 20:47:09 +01:00
David Mann e54f07a4dd Merge PR #10642: Crop: no frame around the cropped image until the next press 2026-11-03 20:47:06 +01:00
David Mann 0ec0255533 Merge PR #9842: Fix Crop Clear retaining custom proportions 2026-11-03 20:47:03 +01:00
David Mann 5c3f2e1392 Merge PR #10950: Crop: a Ctrl-held Straighten line keeps the frame size (#10870) 2026-11-03 20:46:59 +01:00
David Mann d93e65d001 Merge pull request #10952 from davidmann4/integration-batch-13
Integration batch 13: 13 parity epic PRs on main
2026-11-03 20:37:25 +01:00
David MannandClaude Opus 5.5 72c2585177 Merge origin/main (287352bf92) into integration-batch-13
Conflict in crates/engine/src/jobs.rs: main's save commit point (#5333,
OPEN/COMMITTED/CANCELLED phase) met the batch's cancel state
(CANCEL_REQUESTED/WORK_FINISHED). Both now share one atomic with a
COMMITTED bit; commit vs cancel and the batch's late-cancel guard each
stay one compare-and-swap. A Batch that refuses a repeated or late cancel
reports CancelRefused::Finishing ("finishing") in jobs.cancel's
notCancelled list, still {"cancelled": 0}. control-protocol.md documents both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:35:44 +01:00
FredApps 287352bf92 Merge pull request #5333 from vistaero/codex/streamed-psb-save
Integrate streamed PSB saves with the shared save service
2026-11-03 18:41:16 +01:00
yoriswind 0edb29a18c Merge upstream main into Shear action mapping 2026-11-03 09:32:21 -08:00
skylar taylor-barrick b323c1ba27 Merge pull request #10731 from RickiNano/fix/10665-pasteboard-pick-offcanvas
Don't auto-select off-canvas layers from the pasteboard (#10665)
2026-11-03 18:17:45 +01:00
David Mann 3d36330d94 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	docs/scorecard.md
2026-11-03 18:08:29 +01:00
FredApps 996a52cb8f PSD export: a cancelled save stops between the bands of a banded layer
A banded layer checks the save's interrupt before each band and before each
channel's part of a band. Once cancelled it stops there and doesn't encode the
layer again raw; the incomplete model is discarded by the caller (which returns
IoError::Cancelled), so the save never reaches its commit point and the target
is untouched. Ex carries the export's Interrupt for this.

Addresses CodeRabbit review on crates/io/src/psd_export/banded.rs.
2026-11-03 18:00:23 +01:00
ljkx 3c82477fac Crop: turning the image keeps the frame inside it (#10869)
In the default mode a drag outside the box turns the image, but the
frame never changed, so its corners left the turned image and the crop
filled them with the background colour.

Each move of the turn now starts from the frame at the press: it keeps
its size and place if it fits the turned image, else moves the shortest
way in, else shrinks about its centre (keeping its proportions) to the
largest size that fits. The view follows the frame's centre, so the box
stays put on screen. Ctrl (Cmd) held during the turn leaves the frame as
it was. Content-Aware Fill and a frame already past the canvas keep
today's behaviour (not measured).
2026-11-03 17:57:16 +01:00
FredAppsandClaude Opus 5.5 d07684a379 fix(ui): Overscroll off keeps an image smaller than the window centred (#10488) (#11085)
* fix(ui): Overscroll off keeps an image smaller than the window centred (#10488)

With Tools › Overscroll off, an image smaller than the window stayed
wherever zooming around the pointer left it, inside the window, so
zooming out drifted it off centre. #1718 measured that on Photoshop 25.4,
but two reports of Photoshop itself (#10488, #4799) say it stays centred
with Overscroll off. The owner chose the centred behaviour:
scrollbars::clamp_axis now centres an axis on which the image is no
larger than the view; a larger image still can't leave its edges, and
Overscroll on keeps the free view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ui): Overscroll off centres a fitting rotated image (#10488)

CodeRabbit on #11085: the rotated clamp path was only tested from an
already-centred view. A 600 px square at 45 degrees, started off centre
in a 900-point view, now ends with its screen bounds centred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(ui): keep the rotated centring test within rustfmt's width

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:55:50 +01:00
FredAppsandClaude Opus 5.5 d9dc644db1 fix(ui): Roundness previews its radius within 1 px, and a held frame survives a factor change (#10474) (#11081)
* fix(ui): Roundness previews its radius within 1 px too, and a held frame survives a factor change (#10474)

Follow-up to #10524 from its last CodeRabbit review.

- Roundness's disc reaches floor(r) pixels along an axis, so at 6 px
  and k = 4 the proxy radius 1.5 reached 1 proxy px: a 4 px preview for
  a 6 px result. The factor now keeps floor(r / k) * k within 1 px of
  floor(r), as Squareness already does with round (k = 3 at 6 px, k = 2
  at 50 px). One whole-pixel function per mode replaces the special case.
- A canvas test drives the worker across a factor change: the frame for
  k = 2 stays on the canvas, at its own size, until the k = 1 frame lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ui): a fractional Roundness radius previews closer at its chosen factor (#10474)

CodeRabbit on #11081: only the factors were pinned. At 6.5 px the
Roundness preview at the chosen k = 3 is compared with OK's result and
must differ less than the k = 4 preview did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): a mid-drag frame for the previous proxy factor still reaches the canvas (#10474)

CodeRabbit on #11081: the display gate keeps a held frame at any factor,
but the mid-drag publish arm still required the same factor, so a frame
finished for the factor the drag just left was dropped. It now uses
same_target_at_any_scale too; a frame already exact for the dialog is
still never displaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:55:42 +01:00
d21c403e5f Type: add OpenType Numerals submenu (figure styles) (#11051)
* Type: add OpenType Numerals submenu (figure styles)

Type › OpenType › Numerals sets lnum/onum/pnum/tnum as a radio style
(Default through Tabular Oldstyle). Checkmarks follow the selection
start; TYPE-218-5 scorecard/gaps updated; parity floor 640.

* refactor(engine): one style-at-character lookup for the OpenType menus

feature_on_at and numerals_style_at each walked the runs the same way;
they now share style_at. NumeralsStyle::from_id and numerals_style_of
read the existing tables instead of repeating them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(text): persist OpenType numeral tags through PSD EngineData

Map lnum/pnum/tnum as EngineData bools and the width×height combos as
FigureStyle (Krita/Illustrator), so Numerals choices survive PSD round-trip.

* docs(gaps): note Numerals PSD figure-tag round-trip

* revert(text): keep PSD EngineData to the keys Photoshop writes

Backs out the PSD half of 18610324 and its gaps note (58813de3); the
engine reformat in that commit stays.

- `LiningFigures`, `ProportionalFigures` and `TabularFigures` are not
  Photoshop EngineData keys (the commit says Photoshop only has
  `OldStyle`), so writing them puts keys of our own into users' PSDs.
- The `/FigureStyle` code table (1-4) has no oracle: no Photoshop-authored
  file in photocraft-corpus, and neither ag-psd nor psd-tools reads it.

AGENTS.md rule 3 asks for public specs or observation. PSD persistence
of the numeral styles can come back with a Photoshop-authored oracle.
The Numerals menu still round-trips through .pcraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(text): lock PSD EngineData to Photoshop OldStyle for numerals

Document AGENTS.md clean-room: only onum survives TySh round-trip; lnum/pnum/tnum stay .pcraft-only until a Photoshop-authored corpus oracle.

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:55:35 +01:00
David MannandClaude Opus 5.5 ae0da189a2 Address CodeRabbit review: a cancel after an action's saved snapshot keeps that file
When an action saves and then closes or switches away, Batch writes the
saved snapshot inside the action closure. A cancel landing after that write
made process_one return Cancelled, so the input was reported as skipped
although its output exists. The snapshot write is now final: the cancel
stops only the inputs after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:26:39 +01:00
David Mann 559d3fe0f4 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/engine/src/cmd/file_cmds.rs
#	crates/ui-egui/src/app/files.rs
#	crates/ui-egui/src/dialogs/tiff_options_ui.rs
#	docs/file-format-parity.md
#	docs/gaps.md
#	docs/ui-parity.md
2026-11-03 17:11:53 +01:00
FredApps f67696cb2c Jobs tests: give the gated-save tests a temp dir on every platform
The cancel-path tests used the Unix-only tmp_dir fixture, so the engine's lib
tests did not compile on Windows (E0425 in CI run 41660911095). They get their
own save_dir helper instead.
2026-11-03 16:42:18 +01:00
Raghav Krishna 1171d6cce4 Test that the six overlay drawings are all different 2026-11-03 21:02:36 +05:30
Raghav Krishna 286a152fee Test that each canvas Crop menu row draws its own glyph 2026-11-03 20:47:07 +05:30
292ce6ab55 fix(ui): Minimum and Maximum preview at the scale they apply (#10474) (#10524)
* fix(ui): Minimum and Maximum preview at the scale they apply (#10474)

Filter > Other > Minimum... and Maximum... were missing from FULL_RESOLUTION_PREVIEW, so on
any document large enough to be proxied their live preview was not what OK writes.

preview_factor keeps the largest proxy factor at which no pixel-sized parameter falls below
its command's own minimum, and for these two commands that minimum is 0.2
(engine/src/filters/validation.rs:90). A typed radius of 1.5 px therefore allows k = 4, and
scale_preview_params divides it to 0.375 px. Both kernels then measure that in whole pixels
and return the crop untouched when it reads 0: Squareness is
`let r = radius.max(0.0).round() as i32;` (algo/src/other.rs:83) and Roundness is
`let r = radius.max(0.0); let ri = r.floor() as i32;` (other.rs:23-24). So the dialog showed
the untouched document while OK applied a real 1.5 px (Squareness) or 1 px (Roundness)
effect - the same failure mode Dust & Scratches already has in that list for (#3265). The
repo already asserts this rule for the filters that are listed, in
fixed_size_filters_preview_at_the_scale_they_apply; Minimum and Maximum simply were not in it.

Fail-first: four rows (1.5 px in both Preserve modes, both filters) added to that test failed
with the production change stashed - `assert_eq!(k, 1, "filter.other.minimum")` reported
left: 4, right: 1 - and pass with it. filter_dialog group 177 pass / 0 fail; full
cargo test -p photocraft-ui-egui --lib 4440 pass / 22 fail, a strict subset of the 24
failures a clean 422ca7be checkout gives in this environment (the i18n, font-picker,
type-tool and layer-row tests that need a CRAFT_FONTS_DIR checkout this machine has no
access to); nothing new failed. clippy --all-targets -D warnings clean, fmt --check clean,
cargo xtask layers OK (31 crates), cargo xtask wasm ok.

Not touched, on purpose: the kernel math, the 0.2..500 radius range, and the fractional-radius
question in #10474. Min/max over an integer lattice cannot represent a fractional radius at
all, and the two Preserve options already snap at different thresholds (0.5 px for Squareness,
1.0 px for Roundness). Picking graded output or one shared rule needs reference captures, and
nothing in docs/ records the reference behaviour today; guessing would put the guess in the
tests as the oracle. The question stays open on the issue. No doc row either: #2063 and #3265
appear only in code comments, not in any docs table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): Minimum and Maximum keep one proxy pixel of radius, not a full-size preview (#10474)

Previewing every radius at full size fixed the identity preview but made
a large radius (up to 500 px, Roundness is O(radius) per pixel) cost
seconds to minutes per tick on an ordinary photo. The bug only appears
when radius / k drops under one kernel pixel, so cap k at floor(radius):
1.5 px still previews at k = 1, while 50 px keeps the 1/4 proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): Squareness previews its radius within 1 px of what OK applies (#10474)

Capping k at floor(radius) kept a proxy pixel of radius, but Squareness
rounds the proxy radius: 6 px at k = 4 is round(1.5) = 2 proxy px, an
8 px preview for a 6 px result. Take the largest k whose rounded proxy
radius, scaled back, is within 1 px of round(radius) (k = 3 for 6 px and
50 px); Roundness reads its disc from the unrounded radius and keeps the
floor(radius) cap. The radius is clamped to the command's 0.2-500 range
first, as the engine does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): the canvas keeps the last preview frame when a drag changes the proxy factor (#10474)

Minimum and Maximum's factor now follows the radius, so a radius drag
crossing a whole pixel changed FilterPreviewKey::k, and the display gate
(same_target, which includes k) dropped the held frame: the canvas showed
the unfiltered document until a frame for the new factor landed. The
frame is drawn at its own k (textures read preview.key.k), so the gate
now forgives the factor alone (same_target_at_any_scale); publishing and
freshness keep the strict checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-03 16:12:16 +01:00
FredApps 26d151d061 Jobs: a save past its commit point can't be reported as cancelled
jobs.cancel, cancel_all_jobs and closing a document (cancel_jobs_on) cancelled
any running job, so they could report a save as cancelled while its worker
went on to replace the file. The point of no return was tracked only by the
desktop shell's own Cancel/Esc path.

The commit point now lives in the engine's JobCtx: commit() and a cancel race
on one compare-and-swap, so exactly one wins.
- Cancelled before commit: commit() fails, the save writes nothing and the
  job ends Cancelled.
- Committed: every cancel path leaves the job running and says so
  (Session::try_cancel_job -> CancelRefused::Committing; cancel_all_jobs and
  cancel_jobs_on return a CancelReport). The job then reports its real
  outcome, applied even if its document was closed meanwhile.
- jobs.cancel (engine and desktop control) replies {cancelled: n} as before,
  plus notCancelled: [{job, reason: "committing"}] for a save left running.
  This is not an error: the job is fine and will report how it ended.
  Single-job print refusals are unchanged.
- The shell's SaveCtl::commit and Cancel/Esc use the engine gate, so the
  status bar, control and scripting agree.

Tests: cancel before commit (target untouched, Cancelled), cancel after
commit (refused, Done, file written), cancel_all and document close with
mixed committed/open jobs, a commit/cancel race, and desktop jobs.cancel
before and during the write with real files.
2026-11-03 16:09:07 +01:00
Yuki-init e3a84b628d TIFF Options: compression, image pyramid and Save Transparency (#8827) (#10932)
Offer TIFF compression, image pyramid and transparency options on Save As and Save a Copy, with matching CLI and automation parameters.

Follow-up: preserve the unsaved state when a copy discards layers over the document's own file, and check the other export formats for the same issue.

Closes #8827.
2026-11-03 15:39:15 +01:00
Hartmut NörenbergandClaude Opus 5.5 2fff1f8688 fix(ui): Ctrl+Alt + wheel zooms the canvas like Alt + wheel (#10488) (#10517)
* fix(ui): Ctrl+Alt + wheel zooms the canvas like Alt + wheel (#10488)

Ctrl+Alt+wheel over the canvas did nothing useful because two conditions in
wheel_nav.rs excluded it from the Alt gesture:

    let latest_alt = wheel.last().map(|(_, _, m, _)| m.alt && !m.command && !m.ctrl);

and, in the raw-event accumulator, every branch was filtered with
`!m.command && !m.ctrl`. Ctrl+Alt+wheel therefore never set the tracked `alt`
and never contributed a notch to `raw`: with Preferences > General > Zoom with
Scroll Wheel ON it produced nothing at all (raw stayed zero, so the zoom read an
eased ~0), and with it OFF it fell through to classify's `_` arm and merely
scrolled vertically, because egui keeps vertical_scroll_modifier = ALT and does
not fold the delta onto x when both scroll patterns match. The combination is a
dead/incorrect gesture today, so no working behaviour is being replaced.

The new `alt_gesture` helper forgives `command` only when `ctrl` is set as well.
winit mirrors a held Ctrl on Windows (and Linux) into BOTH ctrl and command, so
a bare `!command` test can never match what a Windows reporter typed; macOS
Cmd+Alt (command set, ctrl not) is left out of the zoom gesture (egui 0.37 does not fold it sideways either: with Alt held both scroll patterns match, so its delta stays vertical). `latest_shift` stays strict, so
Ctrl+Alt+Shift+wheel zooms continuously instead of snapping to the ladder, and
the Shift-only and plain branches are untouched, so plain Ctrl+wheel still
scrolls sideways (#635). `legacy_pinch_to_zoom` is untouched and cannot
double-count a fractional Ctrl+Alt pinch step: it matches `modifiers.ctrl`
whatever else is held, but it *replaces* the wheel event with `Event::Zoom`, so
`wheel_events` never sees that step a second time.

Deliberately out of scope: the other half of the report, the centred-zoom /
overscroll complaint. The per-frame view clamp at canvas.rs:1737-1741 runs only
when Preferences > Tools > Overscroll is off (it is on by default), so with the
defaults nothing pulls the view centre back, and what the reporter is seeing is
more likely the deliberate wheel_anchor nearest-image-point clamp
(view_xform.rs:239, #3720) or Zoom Clicked Point to Center. That needs its own
reproduction and issue, and is noted as still open in the dev log.

Tests, fail-first: each_modifier_maps_like_photoshop gained Windows Ctrl+Alt
rows (preference off and on, instant and eased, a fractional pinch step counted
once, and a macOS Cmd+Alt guard row) while keeping its Ctrl-only sideways-scroll
rows; new full-app kittest test
input_tests::ctrl_alt_wheel_zooms_the_canvas_like_alt_wheel zooms five notches
around the pointer and holds the document point under it. Both failed before the
production change and pass after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ui): Ctrl + Alt + Shift + wheel is dead today, not a zoom (#10488)

CodeRabbit 4432102938 asked for an assertion on the Ctrl + Alt + Shift combination instead of
only a comment, and for the reason: the filter rejects the wheel event. Both are right. With ctrl
held, latest_shift (wheel_nav.rs:381) is false because it excludes ctrl and command, so the alt
branch of the filter (wheel_nav.rs:413-422) runs and its "!m.shift" drops the notch out of raw;
shift stays false, so the snap branch never takes it either.

The new row in each_modifier_maps_like_photoshop first asserted what my own comment claimed - a
continuous zoom, with Zoom with Scroll Wheel off and Animated Zoom off - and failed exactly that
way: "Ctrl + Alt + Shift + wheel zooms 10%, at once: [0.0 0.0] 1 snaps 0". Pan zero, zoom 1.0,
snaps 0, and the same numbers with Animated Zoom on, while plain Alt + Shift still snaps. The
gesture is dead, and only the ctrl half of it kills it.

So the three places that asserted a continuous zoom are corrected here instead of left to rot: the
module doc (wheel_nav.rs:8-11), the comment above the filter (wheel_nav.rs:407-413) and the parity
row (docs/ui-parity.md:193). They now state that the gesture is dead, point at the test that pins
it, and name the open question. No behaviour change: the repo has no measured Photoshop evidence
for this combination (the issue names only Ctrl + Alt), so the choice - (a) accept Shift in the alt
branch when Ctrl is held, making it zoom like Alt + wheel, or (b) treat it as exactly Alt + Shift
and snap - goes to a maintainer rather than being invented. Option (b) has a measured cost worth
knowing before anyone picks it: plain Ctrl + Shift + wheel pans sideways today ([40,0], zoom 1.0,
snaps 0, and no test covers it), and under (b) that becomes a no-op with Zoom with Scroll Wheel on.

Gates: cargo test -p photocraft-ui-egui --lib -- wheel = 102 passed, 0 failed (with the promised
assertion first: 101 passed, 1 failed); cargo clippy -p photocraft-ui-egui --all-targets
-- -D warnings clean; cargo fmt -p photocraft-ui-egui -- --check clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ui): pin Ctrl + Alt + Shift + wheel with Zoom with Scroll Wheel on (#10488)

Review 4433950859: both Ctrl + Alt + Shift + wheel assertions ran with Zoom with
Scroll Wheel off, while the test comment, the wheel_nav.rs filter comment and the
ui-parity.md Wheel row stated the no-action result unqualified. Measured, not
copied: a temporary probe test ran one Ctrl + Alt + Shift notch (Line unit,
gesture_with, 60 frames summed) in all four preference combinations and printed
(pan, zoom, snaps) through an assert that was made to fail:

    zww=off anim=off ([0.0 0.0], 1.0, 0) | zww=off anim=on ([0.0 0.0], 1.0, 0)
    zww=on  anim=off ([0.0 40.0], 1.0, 0) | zww=on  anim=on ([0.0 40.0], 1.0, 0)

    test result: FAILED. 102 passed; 1 failed; 0 ignored; 0 measured; 4526 filtered out; finished in 2.63s

The suggestion's premise holds: the dead-gesture claim is true only with Zoom
with Scroll Wheel off. With the preference on, alt_gesture keeps the notch out of
the branch's raw, but `alt != zoom_with_wheel` is false so the zoom arms are
skipped, and egui's own smoothed scroll delta (which the raw filter never gates)
pans the notch through the ordinary scroll path: one whole notch, 40 points
vertical, no zoom, no snap, with Animated Zoom off AND on (it only spreads the
same 40 points over later frames).

Probe deleted; the qualified version landed in all three artefacts:
- wheel_nav/tests.rs: the two dead assertions now say "with Zoom with Scroll
  Wheel off", two new assertions pin the preference-on pan (near [0.0 40.0],
  zoom 1.0, snaps 0, both Animated Zoom modes), and the comment and the
  "not even a scroll" sentence carry the preference condition and the numbers.
- wheel_nav.rs classification comment: the dead-gesture sentence is qualified
  the same way.
- docs/ui-parity.md Wheel row: same qualification plus the measured numbers.

Final gates: cargo test -p photocraft-ui-egui --lib -- wheel ->
test result: ok. 102 passed; 0 failed (also re-run after cargo fmt re-wrapped
one over-long assert, same result); clippy -D warnings clean; fmt --check exit
0; cargo xtask layers: OK, 31 crates, no layering violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep Cmd+Ctrl+Alt + wheel off the Alt zoom path, pin AltGr (#10488)

alt_gesture now also requires !mac_cmd when forgiving command, so macOS
Cmd+Ctrl+Alt (command, ctrl, mac_cmd, alt) is not the zoom gesture. Tests pin
the macOS and Windows/Linux modifier shapes and that AltGr reaches egui as
neither ctrl nor alt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep Ctrl+Alt+Shift + wheel as before, correct the AltGr note, pin the Alt key fallback (#10488)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:36:39 +01:00
David MannandClaude Opus 5.5 62c89c87e7 Tests: the Opened Files failed-write case blocks the write with a folder
A path under a regular file can't be resolved on Linux (ENOTDIR), so Batch
refused the whole run before writing and the test failed there. A folder
taking the result's name resolves on every platform and fails the write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:30:25 +01:00
Hartmut NörenbergandClaude Opus 5.5 e16450d4e2 fix(plugins): the install error names the format the file actually is (#10362) (#10633)
* fix(plugins): the install error names the format the file actually is (#10362)

Plug-ins > Install Plug-in pointed at an old Photoshop plug-in (.8bt) answered
with "not a valid plug-in module: not a WebAssembly binary (missing \0asm
header)". That was one fixed message for every file that is not wasm: it said
what we wanted and nothing about the file the person is holding.

format_hint in runtime.rs now reads the magic number and the error says what the
file looks like. For a Windows module the message is (one line in the code,
wrapped here):

  not a valid plug-in module: not a WebAssembly binary (missing \0asm header);
  this file looks like a Windows native executable or library (a .dll, or a
  Photoshop plug-in such as .8bf/.8bt) - PhotoCraft plug-ins are WebAssembly
  modules (.wasm)

7F 45 4C 46 says "a Linux native library (ELF)", CE FA ED FE / CF FA ED FE /
CA FE BA BE / BE BA FE CA say "a macOS native binary (Mach-O, thin or fat)",
50 4B 03 04 / 50 4B 05 06 say "a zip archive". Any other header keeps today's
message byte for byte, so nothing keyed on it changes. The signatures are public
format facts, read with starts_with/get so a short or hostile file cannot panic.
The error variant, the enum, the ABI check, check_code_section and the wasmi
setup are untouched, and native-code loading stays out of scope (PR #7907).

Fail-first, before the production change:

  cargo test -p photocraft-plugins --lib --tests
    lib:  test result: ok. 10 passed; 0 failed
    abi:  test result: FAILED. 19 passed; 1 failed; 0 ignored; 0 measured
          assertion line: "windows dll or .8bt: not a valid plug-in module:
          not a WebAssembly binary (missing \0asm header)" (the MZ fixture that
          was already in the file)
  cargo test -p photocraft-engine --lib -- plugin
    test result: FAILED. 7 passed; 1 failed; 0 ignored; 0 measured; 2518 filtered out

After:

  cargo test -p photocraft-plugins
    lib 10 passed; abi 20 passed; doc-tests 0
  cargo test -p photocraft-engine --lib -- plugin
    test result: ok. 8 passed; 0 failed; 2518 filtered out

The new engine test drives the real plugin.install command with a `path` whose
file starts with MZ bytes, so it covers the menu path (registry::load_file) end
to end, not only the library check.

Gates: cargo clippy -p photocraft-plugins --all-targets -- -D warnings and
cargo clippy -p photocraft-engine --all-targets -- -D warnings (clean; the only
stderr noise is the pre-existing "patch `egui_*` was not used in the crate
graph" cargo warnings); cargo xtask layers ("OK: 31 crates, no layering
violations."); cargo xtask wasm ("  ok     24 crates", "  ok     photocraft-
codecs --features heif", no FAIL line); cargo fmt -p photocraft-plugins --
--check and cargo fmt -p photocraft-engine -- --check (silent, exit 0). The
whole engine --lib suite is not run here: it fails on clean main in this
checkout for font/i18n reasons (no CRAFT_FONTS_DIR). No doc quoted the old
message (git grep "WebAssembly binary" finds only this crate), so nothing to
update there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(plugins): one install-error test per recognised format, tempfile in the engine test (#10362)

Review feedback on #10633. The single looped test in abi.rs is now one test
per signature (PE/COFF MZ, ELF, Mach-O in all four byte orders, zip), one for
an empty or unrecognised file (including signatures cut short, which keep the
old message byte for byte) and one showing a valid WebAssembly module still
loads. The engine test uses tempfile::tempdir (already a dependency of
photocraft-engine) so the directory is removed on a panic, and asserts the
message does not contain the temp path: the hint is static text, so the
message adds no path.

Gates: cargo test -p photocraft-plugins --lib --tests (10 lib, 26 tests);
cargo test -p photocraft-engine --lib -- plugin (8 passed); clippy -D warnings
on both crates; cargo fmt --all -- --check; cargo xtask layers (OK).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(engine): keep the non-wasm install-error test off wasm32, as tempfile is (#10362)

tempfile is a non-wasm32 dependency of photocraft-engine, so the test that
uses it is gated like the crate's other file-system tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:22:58 +01:00
Hartmut NörenbergandClaude Opus 5.5 6b116fadd0 fix(ui): the Stroke Path dialog remembers Simulate Pressure (#10527) (#10583)
* fix(ui): the Stroke Path dialog remembers Simulate Pressure (#10527)

The Stroke Path dialog is a spec-driven Command dialog: `pen_dialog` builds a
fresh `fields` map on every opening (the path name plus each command's own
extras) and never inserted `simulatePressure`, so the checkbox always came back
at the baked-in spec default, off, however often it was applied.
`filter_dialog::remember` cannot cover it either: it bails when a dialog carries
its own `__spec`.

Seed the field from the last applied choice when the dialog opens, and write the
applied choice back when OK runs the stroke. The value lives in the UI-state
preferences' dialogs map under the command id, `path.stroke` - the same key style
as `edit.fill`, `view.newGuide` and `file.print` - so the control channel can
still read it. The read is defensive: anything but an object holding a real bool
falls back to off. Cancel never reaches the write, so it changes nothing, and a
Paths panel Stroke button (no dialog) writes nothing either.

Only Simulate Pressure is remembered: the tool choice still opens on the spec's
default, and the engine's `path.stroke` default stays `false`, so scripts,
Actions and the control channel are unaffected unless they pass the field.

Tests: canvas_tool_menu/tests.rs - the applied choice reopens the dialog, Cancel
leaves the remembered choice alone, a corrupt saved value falls back to off, and
the tool is not remembered. No docs touched: no ui-parity row covers this
behaviour, and the scorecard row that mentions Simulate Pressure is generated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ui): a failed Stroke Path confirmation does not remember Simulate Pressure (#10527)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:22:50 +01:00
Hartmut NörenbergandClaude Opus 5.5 efc3cf04a3 test(ui): pin the options-bar swatch's rounded checker clip (#8741) (#8898)
* test(ui): pin the options-bar swatch's rounded checker clip (#8741)

The fix itself is already on main: 1505c4260 (the shape Fill/Stroke popover
branch) clips the swatch's transparency checker to the rounded corners with
widgets::checker_rounded, and cites this issue. What main does not have is
anything pinning it, so the white specks dark themes showed past the corners
could silently come back.

This test drives the options-bar Fill swatch through the kittest Harness and
asserts that every paint primitive inside the swatch rect is rounded. With the
square checker its light cells painted past the corner radius, which is what
#8741 reported, so the assert fails on the square version and passes on the
clipped one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ui): cover both shape swatch callers and assert the rounded checker is painted (#8741)

The test rendered only the Properties panel although named for the options bar. Now it covers both callers of the shared swatch (Properties Appearance row and the shape tools' options bar) and asserts exactly one rounded textured checker inside each swatch, so a swatch that paints no checker fails too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ui): the swatch checker must be rounded exactly like the fill (#8741)

A checker rounded less than the fill would still show past its corners,
so the test compares the corner radius with the theme's radius_sm, which
the swatch fill uses, instead of accepting any non-zero radius.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:22:41 +01:00
Hartmut NörenbergandClaude Opus 5.5 825a1d169d fix(ui): Save for Web Matte takes only whole colours while typed (#8830) (#8890)
* fix(ui): Save for Web Matte takes only whole colours while typed (#8830)

The Matte field wrote every keystroke into the export settings, so a partial
colour reached the previews: mid-type the settings held `#1111`, or nothing at
all after a select-all plus Backspace, and the pane's encode failed (its error
row shows above the panes).

`matte_field` shows exactly what is typed while the field has focus and commits
only `#rrggbb`, normalised through `color_picker_ui::parse_hex`, or `none` -
the pattern the Color panel's hex field already uses; anything else snaps back
when focus leaves.

aedab302b had already moved the encode error out of the pane row, so the dialog
no longer widens on an invalid matte; this removes the cause and brings back
this PR's first version's width test as a guard for that half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): the Save for Web matte field keeps typed text across window focus (#8830)

CodeRabbit 4431801542 flagged that the Matte field reads its cached text with
ui.memory(|m| m.has_focus(id)) but caches/clears it with resp.has_focus(). The pinned egui
0.37 fork shows the difference is real: Response::has_focus ANDs the OS-window flag
(response.rs 405-407: self.ctx.input(|i| i.focused) && self.ctx.memory(|mem| mem.has_focus(self.id))),
while Memory::has_focus is only self.focused() == Some(id) (memory/mod.rs 913-915), and egui
never clears the focused widget when the window loses focus - Event::WindowFocused(false)
(input_state/mod.rs 443-451) only clears keys and modifiers. So alt-tabbing away deleted the
typed text and the field came back showing the last whole colour.

Both sides now ask the same question. The new test focuses the Matte field, types a partial
#1111, drops and restores RawInput::focused, and asserts the partial text survives and that
finishing the colour still reaches the settings; on the old write side it fails with
the partial Matte is still in the field after the window came back: ["#ffffff"].

Gates: cargo test -p photocraft-ui-egui --lib -- file_ui = 46 passed, 0 failed;
cargo clippy -p photocraft-ui-egui --all-targets -- -D warnings = clean; cargo xtask layers =
OK: 31 crates, no layering violations.

Formatting: CI's `cargo fmt --all -- --check` caught an unwrapped assert in the first version of
the test (tests.rs:586) - I had run test, clippy and layers locally but skipped fmt, so the gate
I left out is the one that broke. The assert is now wrapped as rustfmt wants it and
`cargo fmt -p photocraft-ui-egui -- --check` is clean; that last push is formatting only, no
behaviour or test-logic change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): the Save for Web matte field caches text when focus arrives mid-edit (#8830)

The Matte field asked Memory::has_focus once, before ui.add, and used that one
value for both sides of the cache: what the field shows, and whether typed text
is kept. Those are two different questions. The display side must keep using the
pre-add state: that is the frame as it started. The cache side must ask again
after ui.add, because egui can move focus into the field while the add runs.

In the pinned egui fork (6d518fc) the MiddleClickPaste branch of TextEdit inserts
the pasted text into an unfocused field and only then calls Memory::request_focus
inside the add: text_edit/builder.rs:933-974 (insert at 953-959, request_focus at
968), and Memory::request_focus sets the focus immediately, memory/mod.rs:945-950,
so has_focus is true right after ui.add in the same frame. Reading focus only
before the add left the pasted partial text uncached: the settings parse rejects
the pasted value (the click pos inserts it inside "#ffffff"), the remove branch
ran with the stale focused=false, and the next frame restored the saved colour
although the field now had focus.

Fix: read the pre-add focus for the display value, re-read has_focus after
ui.add for the insert_temp/remove decision.

Regression test drives a real focus-gain-and-text-in-one-frame: hover over the
Matte field, then a single frame carrying egui::Event::MiddleClickPaste
(fork-only event, data/input/event.rs:40) at the field's centre, with
run_steps(1) so the one stale frame is visible. Fail-first output:

    the pasted partial must survive into the next frame, not be restored to the saved colour: ["#ffffff"]

    test result: FAILED. 46 passed; 1 failed; 0 ignored; 0 measured; 4439 filtered out; finished in 0.49s

After the fix: test result: ok. 47 passed; 0 failed. clippy -D warnings,
cargo fmt --check and cargo xtask layers are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ui): pin Save for Web Matte short form, case, paste and blur (#8830)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:22:33 +01:00
Psychotoxical f72165ad42 Layers panel: filter by layer effect (#10880)
Effect comes after Name in the filter-type dropdown. A second dropdown picks
Bevel & Emboss, Stroke, Inner Shadow, Inner Glow, Satin, Overlay, Outer Glow or
Drop Shadow, and the panel lists the layers with that effect turned on.
Overlay covers Color, Gradient and Pattern Overlay. ui.set {layerFilter}
takes the effect by id.
2026-11-03 15:17:12 +01:00
FredApps 43f415bcf0 Type tool tests: let the options bar harness settle in up to 16 passes
options_font_family_preserves_peer_faces_and_batches_layer_flags failed
under a loaded parallel run at its first `h.run()` after opening the
family field:

  Harness::run exceeded max_steps (8). It would have settled after 10
  steps.

The test has no timeout of its own. The limit it hits is the egui_kittest
`max_steps` cap of the shared `controls()` harness. The font popup fills
in over several passes, and egui 0.37's `widget_style` requests one more
repaint for each widget it has not seen yet, so `run` settles only after
the last batch of the font list has arrived. Alone that takes six passes,
but under load the list arrives in more batches and it took ten.

Raise the cap from 8 to 16, well above the ten seen. It stays bounded: a
UI that keeps requesting repaints still fails, because `run` errors on any
pass past the cap.
2026-11-03 15:07:24 +01:00
FredApps 1b7b56d9f5 GPU canvas: the open documents share one texture budget
The canvas budget was each document's: N open documents could hold N
times it (#5332 review). It is now all of them together. The app
publishes the documents on screen (the arranged tiles or the main
canvas's, plus open document windows) with each one's texture set size;
those split the budget max-min fairly, and fits_canvas, canvas_fit and
the viewport page limit use a document's share, so a document over it is
drawn from pages. A document alone on screen still has the whole budget.

Documents no window shows keep their textures while there is room, so
going back to a tab is free, and are freed first, least recently shown
first, when a shown document's texture set or pages need the room.
Previews and adjustment proxies are never freed for another document.
The split changes only when the shown set or a size changes, not with
focus, so switching between shown documents moves no texture.
2026-11-03 14:51:37 +01:00
David MannandClaude Opus 5.5 a0418a2e29 Address CodeRabbit review: Batch failures, late Esc, CLI format, bridge result
- A failed input stays in `errors` when `jobs.cancel` or Esc lands while it runs, instead of
  showing up as skipped without its reason.
- Esc after the last input is refused: the worker closes the interrupt window once no
  checkpoint is left, so `request_batch_interrupt` no longer answers true for a question
  that never comes.
- `photocraft-cli batch` without `--format` saves a file PhotoCraft only opens as PSD, the
  format `--naming`'s {ext} already plans, instead of asking the encoder for the input's
  import-only extension.
- `--recursive` help says the output is flat.
- The app bridge keeps a cancelled Batch's partial result in the error it returns to MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:50:52 +01:00
FredApps 64c6385684 Viewport pages: keep the pages another window on the document shows
Two windows can show one document (Window > Arrange > New Window for
Document) and share its stream, but each frame treated only its own
wanted pages as live: it cancelled the other window's jobs, dropped its
finished results, evicted its pages first and pruned them as finer than
its own view, so the windows undid each other's work every frame.

Record each window's wanted pages by viewport id. Jobs and results for
any window's pages are kept, pruning skips them, and eviction takes
pages no window shows before another window's. A window not seen for
VIEW_TTL updates (closed) releases them. With one window nothing
changes.

Addresses CodeRabbit review r4453118205 on #5332.
2026-11-03 14:30:02 +01:00
FredApps 0a97fa7f75 GPU canvas: catch errors creating or writing a viewport page
upload_page created the page texture, uniform buffer and bind group and
wrote the texels with no error scope, so an out-of-memory error there
reached the uncaptured-error handler and disabled the GPU canvas for the
session. Share DocTextures::new's validation/out-of-memory scopes as
catch_gpu_errors and run the page work inside them: a refused page
fails alone (the view falls back to the CPU canvas) and the device stays
usable.

Addresses CodeRabbit review r4453118114 on #5332.
2026-11-03 14:30:02 +01:00
FredApps e291eef6d6 Viewport pages: free a document's pages when no page fits the budget
With a canvas budget too small for one page, show() returned to the CPU
canvas after creating or invalidating the stream but before pruning, so
the stream's GPU pages and its jobs stayed held. Check the page budget
first and drop the stream and its pages, as a document that fits does.

Addresses CodeRabbit review r4453118176 on #5332.
2026-11-03 14:30:02 +01:00