Transforming the painting symmetry could only be applied with Enter or
dropped with Esc, which a pen or touch user may have no key for, and the
canvas kept the brush outline although a press there doesn't paint.
While the transform is open, the options bar now shows "Transform
Symmetry" with commit and cancel buttons, like Free Transform's bar. The
canvas shows the move cursor over the centre, the rotate cursor over the
rotation handle and the arrow elsewhere.
Test: transform_has_commit_and_cancel_buttons_and_its_own_cursors.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The profile menu offered every built-in and installed profile, and picking
one of another colour space only produced "profile is Rgb; the document is
Cmyk" in the status bar. The menu now lists the profiles the command accepts
for the document's mode, and a remembered choice that doesn't fit falls back
to the working space.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Use the shared percentage row so the jitter amount does not look like a hue selection. Cover the rendered themed track, numeric and pointer edits, neighboring settings and engine journal replay.
Addresses the Hue Jitter appearance item in #3825; other assessment items remain open.
Co-authored-by: augrclk <217665348+augrclk@users.noreply.github.com>
Co-authored-by: GPT-6 <noreply@openai.com>
⇧⌘N opens the New Layer dialog, as Layer › New › Layer… does in Photoshop.
Photoshop's companion shortcut, the same keys with ⌥ (Ctrl+Alt+Shift+N on
Windows and Linux), makes the layer at once; PhotoCraft didn't handle it,
so there was no way to get 0.7.0's dialog-free behaviour from the keyboard.
New Layer's shortcut with ⌥ added now runs layer.new.layer directly. It
follows a rebound New Layer shortcut, and a command bound to that key
itself keeps it.
Fixes#4794
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
In the Brush Preset picker's cards the tip thumbnail grew to 44.8 pt in a
42 pt cell while the size number was anchored to the cell's bottom, so
the number of any medium or large brush (Hard Round's 30, Soft Round's
45) was drawn under the tip and couldn't be read.
card_tip_rect now keeps a band for the number at the bottom of the cell
and fits the tip above it, still scaled by the preset's thumb_scale so
big brushes read bigger than small ones. Compact cards (no number) are
unchanged.
Test: card_tips_never_cover_their_size_number checks every footer scale
that shows numbers, in all three card kinds.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A brush dab on an A4 300 ppi CMYK page spent ~5 ms per touched tile in the
GPU canvas's tile upload: every pixel of the 256x256 tile went through the
CMYK -> sRGB transform on one thread. CMYK and Lab tiles now convert in
16-row bands in parallel, and a run of identical pixels (the transparent or
flat area around a dab) is converted once. Texels are bit-identical to the
per-pixel conversion.
The band workers enter the document's CMYK profile scope; before, the rayon
workers converting tiles did not, so a CMYK document tagged with its own
profile uploaded tiles through the built-in coated CMYK instead.
P33 (Windows 11, 32 threads, RTX 5070 Ti, 15 reps): p95 13.7-16.8 ms -> 2.6-3.3 ms.
enforce stays false: the committed baseline machine measures every row
over budget, so it is left for the next baseline.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GpuCanvas::retain called forget_doc only for documents still in its
texture map. A document whose entry had been dropped earlier (texture
creation failed, a fresh render failed after uploading, or the canvas
outgrew its budget after Canvas Size) kept its resident pages, effect
maps and lower-stack composite after it was closed, until another
document pushed the budgets over; with no other GPU document, for good.
Compositor::retain_docs forgets every document the caller doesn't list
as open, whatever the texture map says, and retain calls it each time.
The open documents' ids, their preview ids and the adjustment proxies
are already in that list, so nothing in use is dropped.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Surface::translated, under every layer move and the Move drag preview, allocated
every target tile that a moved source tile reached. For a move that isn't a whole
number of tiles, the transparent margins of a layer's edge tiles landed on target
tiles of their own, and those were stored though they hold only the default pixel
(256 KB each at 8-bit RGBA). #4414's shared uniform tiles were also stored when
their value was the default. Moving two 300 px blocks by (100, 56) left 10 of 18
stored tiles empty; nudging back and forth kept adding more.
A target that ends up holding only the default pixel is no longer stored, as
prune() would drop it. A copied tile is compared row by row with a row of the
default pixel after the copy (one memcmp per row; a tile with content stops at
its first row), and a shared uniform tile of the default value is skipped. Moves
by whole tiles still re-share the existing tiles unchanged.
Release, 24 cores, cargo xtask perf --quick (perf_scenarios, p50, HEAD vs this,
two runs each): move layer on 16-bit 15000x10000 + refresh 5.3 -> 3.9 ms; move
layer on 32-bit 20 layers + refresh 11.9 -> 5.9 ms; the other Move rows within
noise. translated() alone on 36 MP: full layer unchanged (27.0 -> 27.6 ms RGBA8);
40 sparse blocks 11.8 -> 10.6 ms RGBA8, 44.0 -> 39 ms RGBA16, 308 -> 190 tiles.
The new test covers RGBA8, RGBA16, RGBA32F and GRAY8 over a zero and a non-zero
default pixel, and both paths; it fails on the old code.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
merged_composite showed a CMYK merged image through the built-in coated
profile, while the compositor reads the file's layers through the
embedded one. Every oracle comparison of a tagged CMYK file therefore
measured two different profiles. psd-tools' CMYK files were 88-100% off
even with nothing to adjust: colormodes/4x4_8bit_cmyk.psd, a plain
image, was 100% off.
It now converts inside the document's CMYK space (compose::cmyk_space).
The test reads the merged inks back and checks them against the embedded
and built-in conversions.
psd-tools, bad pixels (pass count unchanged at 213/309):
- invert_cmyk: 98% -> 15%
- levels_cmyk: 95% -> 14%
- curves_cmyk: 98% -> 37%
- threshold_cmyk: 65% -> 7%
- posterize_16bits_cmyk: 88% -> 13%
- 4x4_8bit_cmyk: 100% -> 6%
With #4945's ink compositing, invert_cmyk and
brightnesscontrast_legacy_cmyk pass (215/309).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
pHYs holds whole pixels per metre, so 300 ppi is stored as 11811 and
read back as 299.9994 (72 as 72.009). The ppi read is now the whole
number that rounds to the stored value when there is one, as Photoshop
reads it; other values stay as they are.
Fixes#4855
Photoshop CC brush presets store the Dual Brush spacing the panel shows in
the dual tip (dualBrush > Brsh > Spcn), and also write a Spcn of 100 % on
the dualBrush object beside it. The importer read that outer value last,
so every dual brush came in at 100 % spacing (the reporter's 10 %, 18 %
and 24 % presets all did), which changes the stroke a lot.
The tip's spacing now wins; the outer value only stands in for a tip
without one.
Fixes part 1 of #4809
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): show uppercase RGB hex values in shared color picker
* test(text): expect uppercase hex in text style color picker
* fix(ui): uppercase displayed hex without changing color storage
* test(text): keep color serialization independent of picker display casing
When appearance mode was toggled between dark and light with default
settings, PhotoCraft switched from Pro Medium (Pro layout) to Studio Light
(Studio layout), altering the UI and displaying a Fit button in the status
bar.
Now that the Photoshop Pro light brightness levels exist (#2661), default
the saved light theme to Pro Light so the Pro interface layout and status
bar are preserved when toggling appearance. Also map the "light" alias to
Pro Light and list the Pro options first under Light Theme preferences.
Fixes#5016.
A right-click (or long press) on a tool button opens the flyout of the
tools grouped with it. Any press outside the flyout closed it, but the
press-outside check leaves out the tool button itself, so clicking that
button again, with either mouse button, left the flyout open.
A click on the button of its open flyout now closes it, as in Photoshop:
a right-click just closes it, a left click closes it and picks the tool
as any click does. The release that ends the long press that opened the
flyout still keeps it open.
Fixes#4932
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
In v0.7.0 the options bar's Fill swatch only previewed the foreground
colour, so a drawn shape's fill could not be changed from it while Stroke
could. Main fixed that in #3019 and #3370; this test covers the reported
steps (draw a rectangle, pick a colour from the Fill swatch): the fill
changes, the stroke does not, and it is one undo step.
* Brush Settings: no section title above the options pane (#3477)
The options pane repeated the selected section's name as a title line.
The selected row in the section list already names it, as in Photoshop,
so the title is gone and the options start at the top of the pane.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* i18n test: the brush section name is drawn once, without a heading (#3477)
dynamic_brush_sections_have_translations_and_draw_in_the_selected_language
still expected the translated section name twice: the list row and the
options pane heading this branch removes. It now expects the row alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
fit_view capped the fit at 1.0 in egui points and then multiplied by
pixels-per-point, so on a 150% display a small image opened at 150%. Since
View::zoom became physical pixels per document pixel (27263bb16, #1978) the
cap has to apply after the conversion: scale by ppp first, then min(1.0).
The test for fit_view asserted the old value (zoom 2.0 at ppp 2.0); it now
checks a small image opens at 1.0 for ppp 1.0, 1.5 and 2.0 and that a large
image still shrinks to fit.
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
photocraft_psd::fixtures (feature testgen) generates the FILE-216-2
fixtures at test time: a 20 MP layered PSD and a 150-layer A4 layout
(groups, masks, hidden layers, drop shadows, known merged colours), a
2.02 GB PSD just under Photoshop's cap and the 4-5 GB PSBs, streamed row
by row. io tests/large_files.rs opens, checks, saves and re-opens each
(the 2 GB PSD and 4.38 GB PSB are opt-in). The ui-egui large_file_bench
times opening the big two, wired to P20 and a new P60.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Photoshop CS2 stores the phfl version 3 colour as XYZ divided by the D50
white point (X / 0.9642, Y, Z / 0.8249), so white is (32768, 32768, 32768).
We read those values as absolute D50 XYZ, giving every CS2 Photo Filter a
slightly wrong colour: the Yellow filter preset (24191, 25002, 3827) decoded
to #FFE03A instead of #F9E31C, and Warming Filter (85) (15548, 12029, 0) to
#F18600 instead of #EC8A00. Multiply by the white point before converting;
decoded this way, every version 3 colour in the reporter's CS2 files is
exactly a Photoshop preset (#4940).
Fixes#4940
Co-authored-by: highperfocused <highperfocused@pm.me>
Remove Background (and Select Subject's full-resolution refinement) could
hang at "Finding subject" on some photos. One refinement tile's max-flow
never finished, and the job ignores Cancel there.
Push/relabel's admissibility test was `dist[i] == dist[j].saturating_add(1)`.
A global relabel gives nodes reachable from neither terminal the height
u32::MAX, and MAX.saturating_add(1) == MAX, so two such neighbours counted
as admissible for each other. When f32 rounding strands excess on them
(source capacities near 3e8 have an ulp of 32), they pushed it back and
forth forever. Use checked_add so an unreachable node is never a push
target; its excess stays put and the loop ends.
The regression test is a 6-node graph that looped before and now matches
the Boykov-Kolmogorov cut.
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Lab skips the native single-layer path (the codecs have no Lab layout)
and goes through the sRGB composite, which always warned that the
layers were flattened. With a single plain layer nothing is flattened:
only the sRGB conversion is reported now.
Fixes#4852
Save for Web subsampled the chroma (4:2:0) up to quality 89. Photoshop
2025's Save for Web writes 4:2:0 up to quality 50 and 4:4:4 from 51
(measured on its output; its Save As keeps 4:2:0 at every quality, as
PhotoCraft's Save As already does).
Fixes#4822
#426 gave smart objects a projective placement (`SmartObject.perspective`,
with `transform` as its affine approximation): Distort and Perspective set
it, the layer re-renders from its source through the homography, Warp
composes with it, and the PSD placement reads and writes
`nonAffineTransform` / `Trnf`. The scorecard row still said "missing" with
the pre-#426 note, as did the ui-parity row and the #579 mention in gaps.md.
Protect Unix bundle cleanup with pinned directory handles and remove only recognized content-addressed files. Keep legitimate linked roots and write/search-only directories working, with regression coverage for GC races and permission failures.
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Background Eraser: main now samples and matches against the pre-stroke pixels (#4552); the
shared dab effect takes the pre-stroke surface so the live preview, whose working areas are
per dab, reads the same pixels as the commit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>